Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
985c9ce07e | ||
|
|
790e8e1dbc | ||
|
|
ef6284d5cc |
No files matched your search
@@ -14,7 +14,7 @@ class ChangeCostUnitDetailsCommand {
|
||||
|
||||
$this->request->costUnit->distance_allowance = $this->request->distanceAllowance->getAmount();
|
||||
$this->request->costUnit->mail_on_new = $this->request->mailOnNew;
|
||||
$this->request->costUnit->billing_deadline = $this->request->billingDeadline;
|
||||
$this->request->costUnit->billing_deadline = $this->request->billingDeadline?->format('Y-m-d');
|
||||
|
||||
$response->success = $this->request->costUnit->save();
|
||||
return $response;
|
||||
|
||||
@@ -17,7 +17,7 @@ class CreateCostUnitCommand {
|
||||
'name' => $this->request->name,
|
||||
'tenant' => currentTenant()->slug,
|
||||
'type' => $this->request->type,
|
||||
'billing_deadline' => $this->request->billingDeadline,
|
||||
'billing_deadline' => $this->request->billingDeadline?->format('Y-m-d'),
|
||||
'distance_allowance' => $this->request->distanceAllowance->getAmount(),
|
||||
'mail_on_new' => $this->request->mailOnNew,
|
||||
'allow_new' => true,
|
||||
|
||||
@@ -37,7 +37,10 @@ class EditController extends CommonController{
|
||||
|
||||
$saveParams = $request->get('formData');
|
||||
$distanceAllowance = Amount::fromString($saveParams['distanceAllowance']);
|
||||
$billingDeadline = isset($saveParams['billingDeadline']) ? \DateTime::createFromFormat('Y-m-d', $saveParams['billingDeadline']) : null;
|
||||
$rawBillingDeadline = $saveParams['billingDeadline'] ?? null;
|
||||
$billingDeadline = !empty($rawBillingDeadline)
|
||||
? (\DateTime::createFromFormat('!Y-m-d', $rawBillingDeadline) ?: null)
|
||||
: null;
|
||||
|
||||
$request = new ChangeCostUnitDetailsRequest($costUnit, $distanceAllowance, $saveParams['mailOnNew'], $billingDeadline);
|
||||
$command = new ChangeCostUnitDetailsCommand($request);
|
||||
|
||||
@@ -26,6 +26,18 @@ class SaveInvoiceController extends CommonController
|
||||
]);
|
||||
}
|
||||
|
||||
// Sicherheitsnetz hinter der Oberfläche: Wer gereist ist, steht im Zahlungsgrund auf Beleg und EüR und
|
||||
// ist bei Fahrtkosten Pflicht. Geprüft wird hier und nicht im Command, weil auch Abrechnungskorrekturen
|
||||
// durch den Command laufen -- sie bringen ihren Zahlungsgrund mit, aber keine Personen. Und vor dem
|
||||
// Upload, damit eine abgelehnte Einreichung keinen verwaisten Beleg hinterlässt.
|
||||
if ($invoiceType === InvoiceType::INVOICE_TYPE_TRAVELLING
|
||||
&& Validator::make($request->all(), ['travellers' => 'required|string'])->fails()) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => 'Bitte gib an, welche Personen gereist sind.'
|
||||
]);
|
||||
}
|
||||
|
||||
$uploadedFile = null;
|
||||
if (null !== $request->file('receipt')) {
|
||||
$maxFileSize = env('MAX_INVOICE_FILE_SIZE', 16);
|
||||
|
||||
@@ -41,10 +41,11 @@ const travelReasonComplete = computed(() =>
|
||||
/**
|
||||
* Wer gereist ist, steht später als Zahlungsgrund in der Belegliste, auf der EüR und auf dem Beleg. Wer
|
||||
* den Beleg einreicht, war meist selbst dabei -- deshalb der eigene Name als Vorschlag. Ohne Login
|
||||
* bleibt das Feld leer, und es hält den Ablauf auch dann nicht auf: Ein Name ist eine Hilfe, keine
|
||||
* Bedingung.
|
||||
* bleibt das Feld leer; weiter geht es erst, wenn jemand eingetragen ist -- wie beim Reisegrund.
|
||||
*/
|
||||
const travellers = ref(data.userName || '');
|
||||
|
||||
const travellersComplete = computed(() => travellers.value.trim() !== '');
|
||||
const have_receipt = ref('')
|
||||
const amount = ref(0.00);
|
||||
const invoiceType = ref(null);
|
||||
@@ -121,7 +122,7 @@ function handleFileChange(event) {
|
||||
/>
|
||||
</fieldset><br /><br />
|
||||
|
||||
<fieldset v-if="travelReasonComplete">
|
||||
<fieldset v-if="travelReasonComplete && travellersComplete">
|
||||
<legend><span style="font-weight: bolder;">Bist du mit dem ÖPNV gefahren oder besitzt du einen Beleg</span></legend>
|
||||
<input type="button" style="border-radius: 0; width: 100px;" @click="have_receipt='yes'" value="Ja" />
|
||||
<input type="button" style="border-radius: 0; width: 100px;" @click="getDistanceAllowance" value="Nein" />
|
||||
@@ -129,7 +130,8 @@ function handleFileChange(event) {
|
||||
|
||||
<br /><br />
|
||||
|
||||
<fieldset v-if="have_receipt === 'yes'">
|
||||
<!-- Auch nach "Ja"/"Nein" gilt die Pflicht: Wer die Personen wieder löscht, kann nicht einreichen. -->
|
||||
<fieldset v-if="travellersComplete && have_receipt === 'yes'">
|
||||
<legend><span style="font-weight: bolder;">Wie hoch ist der Betrag?</span></legend>
|
||||
<AmountInput v-model="amount" class="width-small" id="amount" name="amount" /> Euro
|
||||
|
||||
@@ -161,7 +163,7 @@ function handleFileChange(event) {
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset v-else-if="distanceAllowance != null">
|
||||
<fieldset v-else-if="travellersComplete && distanceAllowance != null">
|
||||
<legend><span style="font-weight: bolder;">Reiseinformationen</span></legend>
|
||||
Gesamtlänge des Reisewegs:
|
||||
<NumericInput
|
||||
|
||||
@@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
/**
|
||||
* @property string $name
|
||||
* @property string $type
|
||||
* @property string $billing_deadline
|
||||
* @property string|null $billing_deadline Y-m-d, Abrechnungsschluss einschließlich des ganzen Tages
|
||||
* @property string $distance_allowance
|
||||
* @property boolean $mail_on_new
|
||||
* @property boolean $allow_new
|
||||
|
||||
@@ -49,6 +49,7 @@ class CostUnitResource {
|
||||
$data = array_merge(
|
||||
$this->costUnit->toArray(),
|
||||
[
|
||||
'billingDeadline' => $this->costUnit->billing_deadline,
|
||||
'distanceAllowanceSmall' => new Amount($this->costUnit->distance_allowance, '')->toString(),
|
||||
'distanceAllowanceFull' => new Amount($this->costUnit->distance_allowance, ' Euro')->toString(),
|
||||
'totalAmount' => new Amount($totalAmount, ' Euro')->toString(),
|
||||
|
||||
@@ -239,8 +239,8 @@ class EventResource extends JsonResource{
|
||||
}
|
||||
|
||||
public function getMultiplier() : float {
|
||||
$earlyBirdEnd = $this->event->early_bird_end;
|
||||
if ($earlyBirdEnd > now()) {
|
||||
// Der Frühbucherpreis gilt einschließlich des ganzen Stichtags.
|
||||
if (now() <= $this->event->early_bird_end->copy()->endOfDay()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ use Illuminate\Support\Facades\Mail;
|
||||
class CloseCostUnit implements CronTask {
|
||||
public function handle(): void
|
||||
{
|
||||
$now = now();
|
||||
$costUnitRepository = new CostUnitRepository();
|
||||
$now = now();
|
||||
$billingResetEnd = clone $now;
|
||||
@@ -25,7 +24,7 @@ class CloseCostUnit implements CronTask {
|
||||
$billingEnd = $costUnit['billing_deadline'];
|
||||
|
||||
if ($billingEnd === null) {
|
||||
$billingEnd = \DateTime::createFromFormat('Y-m-d H:i:s', $billingResetEnd)->format('Y-m-d H:i:s');
|
||||
$billingEnd = $billingResetEnd->format('Y-m-d');
|
||||
new ChangeCostUnitDetailsCommand(new ChangeCostUnitDetailsRequest(
|
||||
CostUnit::where('id', $costUnit['id'])->first(),
|
||||
Amount::fromString($costUnit['distance_allowance']),
|
||||
@@ -34,9 +33,9 @@ class CloseCostUnit implements CronTask {
|
||||
))->execute();
|
||||
}
|
||||
|
||||
$billingEndTime = \DateTime::createFromFormat('Y-m-d H:i:s', $billingEnd);
|
||||
$billingEndTime = \DateTime::createFromFormat('!Y-m-d', $billingEnd);
|
||||
if (false === $billingEndTime) {
|
||||
$billingEndTime = \DateTime::createFromFormat('Y-m-d', $billingEnd);
|
||||
$billingEndTime = \DateTime::createFromFormat('Y-m-d H:i:s', $billingEnd);
|
||||
}
|
||||
|
||||
if (false === $billingEndTime) {
|
||||
@@ -56,12 +55,10 @@ class CloseCostUnit implements CronTask {
|
||||
|
||||
continue;
|
||||
}
|
||||
$billingEndTime->setTime(0,0,0);
|
||||
$billingEndTime->add(new \DateInterval('P1D'));
|
||||
// Der Abrechnungsschluss gilt einschließlich des ganzen Tages: bis 23:59:59 darf eingereicht werden.
|
||||
$billingEndTime->setTime(23, 59, 59);
|
||||
|
||||
|
||||
|
||||
if ($billingEndTime < $now) {
|
||||
if ($now > $billingEndTime) {
|
||||
new ChangeCostUnitStateCommand(
|
||||
new ChangeCostUnitStateRequest(
|
||||
CostUnit::where('id', $costUnit['id'])->first(),false, false
|
||||
|
||||
@@ -12,7 +12,8 @@ class CloseEvent implements CronTask {
|
||||
$eventRepository = new EventRepository();
|
||||
/** @var Event $event */
|
||||
foreach ($eventRepository->getAvailable(false) as $event) {
|
||||
if ($event->registration_final_end <= $now ) {
|
||||
// Der Anmeldeschluss gilt einschließlich des ganzen Tages: bis 23:59:59 darf man sich anmelden.
|
||||
if ($now > $event->registration_final_end->copy()->endOfDay()) {
|
||||
$event->registration_allowed = false;
|
||||
$event->save();
|
||||
}
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Der Abrechnungsschluss ist ein Tag, keine Uhrzeit: eingereicht werden darf bis einschließlich 23:59 des Stichtags.
|
||||
* Die Uhrzeit, die bisher mitgespeichert wurde, hat der Cron ohnehin verworfen -- das Abschneiden ändert kein
|
||||
* Verhalten. Erst normalisieren, dann den Typ ändern, damit das ALTER nichts abschneiden muss.
|
||||
*/
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
DB::table('cost_units')
|
||||
->whereNotNull('billing_deadline')
|
||||
->update(['billing_deadline' => DB::raw('DATE(billing_deadline)')]);
|
||||
|
||||
Schema::table('cost_units', function (Blueprint $table) {
|
||||
$table->date('billing_deadline')->nullable()->change();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('cost_units', function (Blueprint $table) {
|
||||
$table->dateTime('billing_deadline')->nullable()->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
# Produktions-Image für endeavour (KeyHelp-Server, siehe docs/implement-endeavour.md).
|
||||
# Unabhängig von docker/prod.Dockerfile, das der bestehende Server weiter nutzt.
|
||||
#
|
||||
# Targets:
|
||||
# app – PHP-FPM mit Code, vendor/ und gebauten Assets
|
||||
# web – nginx, liefert public/ aus und reicht PHP an "app" weiter
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# base: PHP 8.5 mit den Extensions, die mareike braucht
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM php:8.5-fpm-alpine AS base
|
||||
|
||||
ARG UID=2000
|
||||
ARG GID=2000
|
||||
|
||||
RUN apk add --no-cache \
|
||||
imagemagick \
|
||||
libpng \
|
||||
libxml2 \
|
||||
libzip \
|
||||
oniguruma \
|
||||
&& apk add --no-cache --virtual .build-deps \
|
||||
$PHPIZE_DEPS \
|
||||
imagemagick-dev \
|
||||
libpng-dev \
|
||||
libxml2-dev \
|
||||
libzip-dev \
|
||||
oniguruma-dev \
|
||||
&& pecl install imagick \
|
||||
&& docker-php-ext-enable imagick \
|
||||
&& docker-php-ext-install mysqli pdo_mysql mbstring zip exif pcntl gd \
|
||||
&& apk del .build-deps \
|
||||
&& rm -rf /tmp/pear
|
||||
|
||||
# Nutzer mit der UID/GID des Host-Nutzers, damit das eingebundene storage/ beschreibbar ist.
|
||||
# Existiert die GID im Image schon, wird die vorhandene Gruppe verwendet.
|
||||
RUN if getent group "${GID}" > /dev/null; then \
|
||||
GROUP_NAME="$(getent group "${GID}" | cut -d: -f1)"; \
|
||||
else \
|
||||
addgroup -S -g "${GID}" mareike && GROUP_NAME=mareike; \
|
||||
fi \
|
||||
&& adduser -S -D -H -u "${UID}" -G "${GROUP_NAME}" mareike
|
||||
|
||||
COPY --chmod=0755 docker/php/composer.phar /usr/bin/composer
|
||||
COPY docker/endeavour/php.ini /usr/local/etc/php/conf.d/zz-mareike.ini
|
||||
|
||||
WORKDIR /var/www/html
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# vendor: Composer-Abhängigkeiten (ohne Dev-Pakete)
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM base AS vendor
|
||||
|
||||
RUN apk add --no-cache git unzip
|
||||
|
||||
COPY composer.json composer.lock ./
|
||||
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist --no-interaction --no-progress
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# assets: Vite-Build (Vite importiert aus app/, daher der volle Quellcode)
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:24-alpine AS assets
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# app: PHP-FPM
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM base AS app
|
||||
|
||||
COPY . .
|
||||
COPY --from=vendor /var/www/html/vendor ./vendor
|
||||
COPY --from=assets /app/public/build ./public/build
|
||||
|
||||
# Der Code gehört root; beschreibbar sind nur storage/ und bootstrap/cache/.
|
||||
RUN composer dump-autoload --optimize --no-dev --no-interaction \
|
||||
&& mkdir -p \
|
||||
storage/app/private \
|
||||
storage/app/public \
|
||||
storage/framework/cache/data \
|
||||
storage/framework/sessions \
|
||||
storage/framework/views \
|
||||
storage/logs \
|
||||
storage/temp \
|
||||
&& chown -R mareike: storage bootstrap/cache
|
||||
|
||||
USER mareike
|
||||
|
||||
EXPOSE 9000
|
||||
CMD ["php-fpm"]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# web: nginx vor PHP-FPM
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM nginx:stable-alpine AS web
|
||||
|
||||
COPY docker/endeavour/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=app /var/www/html/public /var/www/html/public
|
||||
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,36 @@
|
||||
# Build-Kontext für docker/endeavour/Dockerfile.
|
||||
# BuildKit nutzt diese Datei anstelle der .dockerignore im Projektroot, die für den bestehenden Server
|
||||
# unverändert bleibt. Wichtig: keine .env ins Image – sie wird zur Laufzeit eingebunden.
|
||||
|
||||
.git
|
||||
.ai
|
||||
.junie
|
||||
.claude
|
||||
.idea
|
||||
.fleet
|
||||
.vscode
|
||||
.zed
|
||||
|
||||
.env
|
||||
.env.*
|
||||
|
||||
node_modules
|
||||
vendor
|
||||
public/build
|
||||
public/hot
|
||||
public/storage
|
||||
|
||||
storage/*
|
||||
bootstrap/cache/*.php
|
||||
|
||||
tests
|
||||
.phpunit.cache
|
||||
.phpunit.result.cache
|
||||
|
||||
docker/ssl
|
||||
docker/logs
|
||||
docker-compose.yaml
|
||||
|
||||
_ide_helper.php
|
||||
_ide_helper_models.php
|
||||
gitlog.log
|
||||
@@ -0,0 +1,42 @@
|
||||
# Compose-Stack für endeavour (KeyHelp-Server, siehe docs/implement-endeavour.md).
|
||||
# Wird über /opt/mareike/bin/deploy.sh gesteuert, das die MAREIKE_*-Variablen setzt.
|
||||
|
||||
name: mareike
|
||||
|
||||
x-logging: &logging
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "5"
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: docker/endeavour/Dockerfile
|
||||
target: app
|
||||
args:
|
||||
UID: ${MAREIKE_UID:-2000}
|
||||
GID: ${MAREIKE_GID:-2000}
|
||||
image: mareike-endeavour-app:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ${MAREIKE_ENV_FILE:-/opt/mareike/shared/.env}:/var/www/html/.env:ro
|
||||
- ${MAREIKE_STORAGE:-/opt/mareike/storage}:/var/www/html/storage
|
||||
# Socket der KeyHelp-MariaDB auf dem Host (DB_SOCKET=/run/mysqld/mysqld.sock)
|
||||
- ${MAREIKE_DB_SOCKET_DIR:-/run/mysqld}:/run/mysqld
|
||||
logging: *logging
|
||||
|
||||
web:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: docker/endeavour/Dockerfile
|
||||
target: web
|
||||
image: mareike-endeavour-web:latest
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- app
|
||||
ports:
|
||||
# Nur lokal erreichbar – nach außen geht es ausschließlich über den KeyHelp-Webserver.
|
||||
- "127.0.0.1:${MAREIKE_HTTP_PORT:-8080}:80"
|
||||
logging: *logging
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Deployment von mareike auf endeavour (KeyHelp-Server), siehe docs/implement-endeavour.md.
|
||||
#
|
||||
# Liegt auf dem Server als KOPIE unter /opt/mareike/bin/deploy.sh – nicht direkt aus src/ ausführen,
|
||||
# weil der git-Checkout sonst das laufende Skript austauscht.
|
||||
#
|
||||
# Aufruf (als root):
|
||||
# deploy.sh [REF] Tag, Branch oder Commit ausrollen (Default: main)
|
||||
# deploy.sh artisan … php artisan im laufenden app-Container
|
||||
# deploy.sh compose … docker compose mit der endeavour-Konfiguration (z. B. "compose logs -f")
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MAREIKE_BASE="${MAREIKE_BASE:-/opt/mareike}"
|
||||
MAREIKE_USER="${MAREIKE_USER:-mareike-docker}"
|
||||
SRC="$MAREIKE_BASE/src"
|
||||
|
||||
export MAREIKE_ENV_FILE="$MAREIKE_BASE/shared/.env"
|
||||
export MAREIKE_STORAGE="$MAREIKE_BASE/storage"
|
||||
export MAREIKE_HTTP_PORT="${MAREIKE_HTTP_PORT:-8080}"
|
||||
export MAREIKE_DB_SOCKET_DIR="${MAREIKE_DB_SOCKET_DIR:-/run/mysqld}"
|
||||
MAREIKE_UID="$(id -u "$MAREIKE_USER")"
|
||||
MAREIKE_GID="$(id -g "$MAREIKE_USER")"
|
||||
export MAREIKE_UID MAREIKE_GID
|
||||
|
||||
compose() {
|
||||
docker compose -f "$SRC/docker/endeavour/compose.yaml" "$@"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
artisan)
|
||||
shift
|
||||
compose exec app php artisan "$@"
|
||||
exit
|
||||
;;
|
||||
compose)
|
||||
shift
|
||||
compose "$@"
|
||||
exit
|
||||
;;
|
||||
esac
|
||||
|
||||
REF="${1:-main}"
|
||||
|
||||
if [[ ! -f "$MAREIKE_ENV_FILE" ]]; then
|
||||
echo "Fehlt: $MAREIKE_ENV_FILE (Vorlage: docker/endeavour/env.production.example)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Quellcode auf '$REF' bringen"
|
||||
git -C "$SRC" fetch --tags --prune --force origin
|
||||
if git -C "$SRC" show-ref --verify --quiet "refs/remotes/origin/$REF"; then
|
||||
git -C "$SRC" checkout --force -B "$REF" "origin/$REF"
|
||||
else
|
||||
git -C "$SRC" checkout --force --detach "$REF"
|
||||
fi
|
||||
git -C "$SRC" clean -fd
|
||||
|
||||
echo "==> storage/ vorbereiten"
|
||||
for dir in \
|
||||
"" \
|
||||
app app/private app/public \
|
||||
framework framework/cache framework/cache/data framework/sessions framework/views \
|
||||
logs temp
|
||||
do
|
||||
install -d -m 0775 -o "$MAREIKE_USER" -g "$MAREIKE_GID" "$MAREIKE_STORAGE/$dir"
|
||||
done
|
||||
|
||||
echo "==> Images bauen"
|
||||
compose build --pull
|
||||
|
||||
echo "==> Datenbank migrieren"
|
||||
compose run --rm --no-deps app php artisan migrate --force
|
||||
|
||||
echo "==> Container starten"
|
||||
compose up -d --remove-orphans
|
||||
compose exec -T app php artisan view:clear
|
||||
|
||||
docker image prune -f > /dev/null
|
||||
|
||||
echo "==> mareike $(cat "$SRC/version") ($(git -C "$SRC" rev-parse --short HEAD)) läuft."
|
||||
@@ -0,0 +1,65 @@
|
||||
# Vorlage für /opt/mareike/shared/.env auf endeavour.
|
||||
# Nach dem Kopieren alle <...>-Platzhalter ersetzen. Diese Datei kommt NIE ins Image und NIE ins Repo.
|
||||
# Keine ${...}-Verweise auf andere Variablen verwenden, Werte ausschreiben.
|
||||
|
||||
APP_NAME=mareike
|
||||
APP_ENV=production
|
||||
APP_DEBUG=false
|
||||
# Erzeugen mit: echo "base64:$(openssl rand -base64 32)" – danach NIE mehr ändern (verschlüsselte Daten!)
|
||||
APP_KEY=
|
||||
# Domain des Dach-Tenants "lv"
|
||||
APP_URL=https://<lv-subdomain>.meine-anmeldung.digital
|
||||
# Kein ASSET_URL: Assets kommen von der jeweiligen Tenant-Domain selbst.
|
||||
|
||||
APP_LOCALE=de
|
||||
APP_FALLBACK_LOCALE=de
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=daily
|
||||
LOG_DAILY_DAYS=30
|
||||
LOG_LEVEL=warning
|
||||
|
||||
# KeyHelp-MariaDB auf dem Host, per Unix-Socket (Verzeichnis /run/mysqld ist in den Container gemountet)
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=localhost
|
||||
DB_SOCKET=/run/mysqld/mysqld.sock
|
||||
DB_DATABASE=<datenbankname-aus-keyhelp>
|
||||
DB_USERNAME=<datenbanknutzer-aus-keyhelp>
|
||||
DB_PASSWORD=<passwort>
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_SECURE_COOKIE=true
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
CACHE_STORE=database
|
||||
QUEUE_CONNECTION=sync
|
||||
FILESYSTEM_DISK=local
|
||||
|
||||
# Versand über ein KeyHelp-Postfach (STARTTLS auf 587)
|
||||
MAIL_MAILER=smtp
|
||||
MAIL_SCHEME=smtp
|
||||
MAIL_HOST=<hostname-des-mailservers>
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=<noreply@meine-anmeldung.digital>
|
||||
MAIL_PASSWORD=<passwort>
|
||||
MAIL_FROM_ADDRESS=<noreply@meine-anmeldung.digital>
|
||||
MAIL_FROM_NAME=mareike
|
||||
|
||||
# Empfänger der Admin-Benachrichtigungen (z. B. neue Registrierungen)
|
||||
APP_ADMIN_MAIL=<admin@meine-anmeldung.digital>
|
||||
APP_ADMIN_NAME="<Name>"
|
||||
|
||||
# Maximale Größe hochgeladener Belege in MB (php.ini erlaubt bis 64)
|
||||
MAX_INVOICE_FILE_SIZE=16
|
||||
|
||||
# WebDAV-Ablage: unbedingt ein EIGENER Ordner, getrennt vom bestehenden Server
|
||||
WEBDAV_HOST=<https://cloud.example.org/remote.php/dav/files/<nutzer>/<eigener-ordner>/>
|
||||
WEBDAV_USER=<nutzer>
|
||||
WEBDAV_PASS=<app-passwort>
|
||||
|
||||
# Prüfdienst für erweiterte Führungszeugnisse
|
||||
COC_CHECK_URL=<https://efzcheck.example.org/api/v1/check-efz>
|
||||
|
||||
# MUSS leer bleiben: "bdp-lv-sachsen" würde beim Seeden die Sachsen-Tenants und deren Personen einspielen.
|
||||
PROVIDER=
|
||||
@@ -0,0 +1,54 @@
|
||||
# nginx im Container "web" auf endeavour.
|
||||
# TLS terminiert der KeyHelp-Webserver auf dem Host und reicht per Proxy an 127.0.0.1:8080 weiter.
|
||||
|
||||
# Echte Client-IP aus X-Forwarded-For übernehmen, aber nur von Docker-internen Adressen
|
||||
# (der Proxy auf dem Host erreicht den Container über das Bridge-Gateway).
|
||||
set_real_ip_from 10.0.0.0/8;
|
||||
set_real_ip_from 172.16.0.0/12;
|
||||
set_real_ip_from 192.168.0.0/16;
|
||||
real_ip_header X-Forwarded-For;
|
||||
real_ip_recursive on;
|
||||
|
||||
server {
|
||||
listen 80 default_server;
|
||||
server_name _;
|
||||
|
||||
root /var/www/html/public;
|
||||
index index.php;
|
||||
|
||||
server_tokens off;
|
||||
client_max_body_size 70m;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
# Vite-Assets sind versioniert (Hash im Dateinamen) und dürfen lange gecacht werden.
|
||||
location /build/assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
access_log off;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
try_files $uri =404;
|
||||
fastcgi_split_path_info ^(.+\.php)(/.+)$;
|
||||
fastcgi_pass app:9000;
|
||||
fastcgi_index index.php;
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
|
||||
# Nach außen läuft alles über HTTPS (KeyHelp). So erzeugt Laravel https-URLs,
|
||||
# ohne dass im App-Code trustProxies gesetzt werden muss.
|
||||
fastcgi_param HTTPS on;
|
||||
fastcgi_param REQUEST_SCHEME https;
|
||||
fastcgi_param SERVER_PORT 443;
|
||||
|
||||
fastcgi_read_timeout 300;
|
||||
}
|
||||
|
||||
location ~ /\.(?!well-known) {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
; PHP-Einstellungen für endeavour (Container "app").
|
||||
|
||||
; Uploads: Obergrenze für MAX_INVOICE_FILE_SIZE (MB, Default 16) in der .env, POST etwas größer für die
|
||||
; Formulardaten.
|
||||
upload_max_filesize = 64M
|
||||
post_max_size = 70M
|
||||
|
||||
memory_limit = 512M
|
||||
max_execution_time = 120
|
||||
|
||||
expose_php = Off
|
||||
date.timezone = Europe/Berlin
|
||||
|
||||
; OPcache ist ab PHP 8.5 fest eingebaut. Zeitstempel werden weiter geprüft, weil Blade kompilierte
|
||||
; Views in storage/ unter gleichem Dateinamen neu schreibt.
|
||||
opcache.enable = 1
|
||||
opcache.memory_consumption = 128
|
||||
opcache.max_accelerated_files = 20000
|
||||
opcache.validate_timestamps = 1
|
||||
opcache.revalidate_freq = 2
|
||||
@@ -0,0 +1,410 @@
|
||||
# mareike auf endeavour ausrollen (KeyHelp-Server mit Docker)
|
||||
|
||||
Diese Anleitung beschreibt, wie mareike **zusätzlich** zum bestehenden Server auf *endeavour* läuft. Endeavour ist ein
|
||||
Server, der mit KeyHelp verwaltet wird. Die Parent-Domain ist `meine-anmeldung.digital`.
|
||||
|
||||
KeyHelp selbst nutzt kein Docker. mareike läuft deshalb in zwei Containern **neben** KeyHelp. KeyHelp übernimmt
|
||||
weiter, was es gut kann: Domains, Let's-Encrypt-Zertifikate, MariaDB, Postfächer und Backups.
|
||||
|
||||
> **Der bestehende Server bleibt unberührt.** Für endeavour gibt es eigene Dateien unter `docker/endeavour/`.
|
||||
> `docker-compose.prod`, `docker/prod.Dockerfile`, `docker/run-mareike.sh`, `docker/nginx/default.conf` und
|
||||
> `.dockerignore` werden nicht verändert und von endeavour nicht benutzt.
|
||||
|
||||
---
|
||||
|
||||
## 1. Überblick
|
||||
|
||||
```
|
||||
Browser ──HTTPS──▶ KeyHelp-Webserver (Apache/nginx, Let's Encrypt, eine Domain je Tenant)
|
||||
│ Proxy, Host-Header bleibt erhalten
|
||||
▼
|
||||
127.0.0.1:8080 ──▶ Container "web" (nginx, liefert public/ aus)
|
||||
│ FastCGI
|
||||
▼
|
||||
Container "app" (PHP 8.5-FPM, mareike)
|
||||
│ Unix-Socket /run/mysqld/mysqld.sock
|
||||
▼
|
||||
KeyHelp-MariaDB (Host)
|
||||
```
|
||||
|
||||
- **Tenant-Erkennung:** `IdentifyTenant` sucht den Tenant über `tenants.url` = Host der Anfrage. Deshalb muss der
|
||||
Proxy den Host-Header unverändert weitergeben, und jede Tenant-Domain braucht exakt diesen Eintrag in `tenants.url`.
|
||||
- **HTTPS:** TLS endet bei KeyHelp. Die nginx im Container meldet PHP trotzdem `HTTPS=on`, damit Laravel `https://`-Links
|
||||
erzeugt. Eine Code-Änderung ist dafür nicht nötig.
|
||||
- **Port 8080** ist nur an `127.0.0.1` gebunden und von außen nicht erreichbar.
|
||||
|
||||
### Dateien im Repo
|
||||
|
||||
| Datei | Zweck |
|
||||
|---|---|
|
||||
| `docker/endeavour/Dockerfile` | Multi-Stage-Build. Target `app` enthält PHP-FPM, Code, vendor/ und Assets, Target `web` enthält nginx und public/. |
|
||||
| `docker/endeavour/Dockerfile.dockerignore` | Eigener Build-Kontext. Unter anderem kommt **keine `.env` ins Image**. |
|
||||
| `docker/endeavour/compose.yaml` | Stack mit den Services `app` und `web`. |
|
||||
| `docker/endeavour/nginx.conf` | nginx im Container, inkl. `HTTPS on` und echter Client-IP. |
|
||||
| `docker/endeavour/php.ini` | Upload-Größen, Speicher, OPcache. |
|
||||
| `docker/endeavour/env.production.example` | Vorlage für die `.env` auf dem Server. |
|
||||
| `docker/endeavour/deploy.sh` | Deploy-Skript: pullen, bauen, migrieren, starten. |
|
||||
|
||||
### Verzeichnisse auf endeavour
|
||||
|
||||
```
|
||||
/opt/mareike/
|
||||
├── bin/deploy.sh Kopie von docker/endeavour/deploy.sh
|
||||
├── src/ Git-Checkout (wird bei jedem Deploy aktualisiert)
|
||||
├── shared/.env Konfiguration mit Zugangsdaten (nur root lesbar)
|
||||
└── storage/ Laravel-storage/ (Logs, Sessions, erzeugte PDFs/ZIPs) – persistent
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Docker installieren
|
||||
|
||||
Als root auf endeavour. Das Beispiel gilt für Debian, bei Ubuntu steht in den Pfaden `ubuntu` statt `debian`.
|
||||
|
||||
```bash
|
||||
apt-get update
|
||||
apt-get install -y ca-certificates curl
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
|
||||
chmod a+r /etc/apt/keyrings/docker.asc
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
|
||||
https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
|
||||
> /etc/apt/sources.list.d/docker.list
|
||||
apt-get update
|
||||
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
```
|
||||
|
||||
Log-Rotation für alle Container (`/etc/docker/daemon.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"log-driver": "json-file",
|
||||
"log-opts": { "max-size": "10m", "max-file": "5" }
|
||||
}
|
||||
```
|
||||
|
||||
Docker soll erst **nach** MariaDB starten, weil der Container das Socket-Verzeichnis `/run/mysqld` einbindet:
|
||||
|
||||
```bash
|
||||
mkdir -p /etc/systemd/system/docker.service.d
|
||||
cat > /etc/systemd/system/docker.service.d/after-mariadb.conf <<'EOF'
|
||||
[Unit]
|
||||
After=mariadb.service
|
||||
Wants=mariadb.service
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
systemctl restart docker
|
||||
docker run --rm hello-world
|
||||
```
|
||||
|
||||
> **KeyHelp-Firewall:** Docker legt eigene iptables-Regeln an. Lädt KeyHelp seine Firewall neu, können diese Regeln
|
||||
> verloren gehen. Die Container sind dann nicht mehr erreichbar (502 im Browser). Abhilfe ist
|
||||
> `systemctl restart docker`. Weil mareike nur an `127.0.0.1` lauscht, öffnet Docker nach außen keine Ports.
|
||||
|
||||
---
|
||||
|
||||
## 3. Systemnutzer, Verzeichnisse, Repository
|
||||
|
||||
Die Container laufen mit der UID eines eigenen Host-Nutzers, damit `storage/` sauber beschreibbar ist. Der Name weicht
|
||||
bewusst von den KeyHelp-Konten ab, weil KeyHelp für seine Konten selbst Systemnutzer anlegt.
|
||||
|
||||
```bash
|
||||
getent passwd 2000 || useradd --system --uid 2000 --user-group --no-create-home --shell /usr/sbin/nologin mareike-docker
|
||||
# Ist UID 2000 belegt, eine andere freie UID wählen. deploy.sh liest sie automatisch aus.
|
||||
|
||||
install -d -m 0755 /opt/mareike /opt/mareike/bin
|
||||
install -d -m 0700 /opt/mareike/shared
|
||||
install -d -m 0775 -o mareike-docker -g mareike-docker /opt/mareike/storage
|
||||
```
|
||||
|
||||
Deploy-Key für das Repository anlegen (nur Lesezugriff):
|
||||
|
||||
```bash
|
||||
ssh-keygen -t ed25519 -f /root/.ssh/mareike_deploy -N "" -C "endeavour-deploy"
|
||||
cat /root/.ssh/mareike_deploy.pub # in Gitea beim Repo th.guenther/mareike als Deploy-Key (read-only) eintragen
|
||||
|
||||
cat >> /root/.ssh/config <<'EOF'
|
||||
Host git.zahlenlabyrinth.de
|
||||
Port 9922
|
||||
User git
|
||||
IdentityFile /root/.ssh/mareike_deploy
|
||||
IdentitiesOnly yes
|
||||
EOF
|
||||
|
||||
git clone ssh://git@git.zahlenlabyrinth.de:9922/th.guenther/mareike.git /opt/mareike/src
|
||||
install -m 0750 /opt/mareike/src/docker/endeavour/deploy.sh /opt/mareike/bin/deploy.sh
|
||||
```
|
||||
|
||||
> Ändert sich `docker/endeavour/deploy.sh` im Repo, danach den `install`-Befehl erneut ausführen.
|
||||
|
||||
---
|
||||
|
||||
## 4. Datenbank in KeyHelp anlegen
|
||||
|
||||
1. Im KeyHelp-Panel ein Konto für mareike anlegen oder ein vorhandenes wählen, z. B. `anmeldung`. Unter diesem Konto
|
||||
liegen später auch die Domains.
|
||||
2. Unter **Datenbanken** eine neue MariaDB-Datenbank mit Benutzer anlegen. Der Benutzer ist `@localhost`. Den
|
||||
Remote-Zugriff **nicht** aktivieren, denn der Container verbindet sich über den Unix-Socket und gilt damit als
|
||||
`localhost`.
|
||||
3. Den Socket-Pfad prüfen:
|
||||
|
||||
```bash
|
||||
mysql -e "SELECT @@socket;" # erwartet: /run/mysqld/mysqld.sock
|
||||
```
|
||||
|
||||
Liegt der Socket woanders, in `/opt/mareike/bin/deploy.sh` die Variable `MAREIKE_DB_SOCKET_DIR` auf das Verzeichnis
|
||||
setzen und `DB_SOCKET` in der `.env` anpassen.
|
||||
|
||||
Weil die Datenbank von KeyHelp verwaltet wird, ist sie automatisch im **KeyHelp-Backup** enthalten.
|
||||
|
||||
---
|
||||
|
||||
## 5. `.env` anlegen
|
||||
|
||||
```bash
|
||||
install -m 0600 /opt/mareike/src/docker/endeavour/env.production.example /opt/mareike/shared/.env
|
||||
echo "base64:$(openssl rand -base64 32)" # Ergebnis als APP_KEY eintragen
|
||||
nano /opt/mareike/shared/.env
|
||||
```
|
||||
|
||||
Die wichtigsten Punkte:
|
||||
|
||||
- `APP_KEY` wird einmal erzeugt und dann **nie wieder geändert**, weil Laravel damit verschlüsselte Daten sonst nicht
|
||||
mehr lesen kann.
|
||||
- `APP_URL` ist die Domain des Dach-Tenants `lv` (siehe Abschnitt 6).
|
||||
- `DB_*` sind die Daten aus Schritt 4. `DB_HOST=localhost` und `DB_SOCKET` bleiben wie in der Vorlage.
|
||||
- `MAIL_*`: Am einfachsten ist ein Postfach in KeyHelp, etwa `noreply@meine-anmeldung.digital`. Als Host den
|
||||
Mail-Hostnamen des Servers eintragen, mit Port 587.
|
||||
- `WEBDAV_*`: **eigener Zielordner**, damit sich endeavour und der bestehende Server nicht gegenseitig Dateien
|
||||
überschreiben.
|
||||
- `PROVIDER` **muss leer bleiben.** Mit `bdp-lv-sachsen` würde `db:seed` die Sachsen-Tenants samt Personendaten
|
||||
einspielen.
|
||||
- `ASSET_URL` nicht setzen, dann kommen die Assets von der jeweiligen Tenant-Domain.
|
||||
- In der Datei keine `${VAR}`-Verweise verwenden, sondern alle Werte ausschreiben.
|
||||
|
||||
---
|
||||
|
||||
## 6. Erstinstallation
|
||||
|
||||
### 6.1 Erstes Deployment
|
||||
|
||||
```bash
|
||||
/opt/mareike/bin/deploy.sh main # oder einen Release-Tag, z. B. 4.9.1
|
||||
```
|
||||
|
||||
Das Skript holt den Code, baut beide Images, legt die Tabellen per `migrate` an und startet die Container. Der erste
|
||||
Build dauert ein paar Minuten.
|
||||
|
||||
Kurztest:
|
||||
|
||||
```bash
|
||||
/opt/mareike/bin/deploy.sh compose ps
|
||||
curl -sI http://127.0.0.1:8080/ | head -1 # 404 ist hier richtig: es gibt noch keinen Tenant
|
||||
```
|
||||
|
||||
### 6.2 Stammdaten einspielen
|
||||
|
||||
```bash
|
||||
/opt/mareike/bin/deploy.sh artisan db:seed --force
|
||||
```
|
||||
|
||||
Mit leerem `PROVIDER` läuft nur der `ProductionDataSeeder`. Er legt Rollen, Status, Zahlungsarten, Kostenstellentypen
|
||||
und die Befreiungsgründe an. Den Befehl **nur einmal** ausführen, ein zweiter Lauf würde Duplikate anlegen.
|
||||
|
||||
### 6.3 Dach-Tenant `lv` und Cron-Aufgaben anlegen
|
||||
|
||||
Der Slug `lv` ist im Code fest als Dach-Ebene verdrahtet (`LvOnlyMiddleware`, Registrierung, Tenant-Verwaltung).
|
||||
endeavour braucht deshalb genau einen Tenant mit diesem Slug. Seine `url` ist die Domain, die in Abschnitt 7 in KeyHelp
|
||||
angelegt wird.
|
||||
|
||||
```bash
|
||||
/opt/mareike/bin/deploy.sh artisan tinker
|
||||
```
|
||||
|
||||
```php
|
||||
App\Models\Tenant::create([
|
||||
'slug' => 'lv',
|
||||
'name' => '<Name des Verbands>',
|
||||
'url' => '<lv-subdomain>.meine-anmeldung.digital',
|
||||
'email' => '<kontakt@…>',
|
||||
'email_finance' => '<finanzen@…>',
|
||||
'account_name' => '<Kontoinhaber>',
|
||||
'account_iban' => '<IBAN>',
|
||||
'account_bic' => '<BIC>',
|
||||
'city' => '<Ort>',
|
||||
'postcode' => '<PLZ>',
|
||||
'is_active_local_group' => true,
|
||||
'has_active_instance' => true,
|
||||
]);
|
||||
|
||||
use App\Enumerations\CronTaskType;
|
||||
App\Models\CronTask::create(['name' => 'UploadInvoices', 'execution_type' => CronTaskType::CRON_TASK_TYPE_REALTIME]);
|
||||
App\Models\CronTask::create(['name' => 'CloseCostUnit', 'execution_type' => CronTaskType::CRON_TASK_TYPE_DAILY, 'schedule_time' => '00:05']);
|
||||
App\Models\CronTask::create(['name' => 'CloseEvent', 'execution_type' => CronTaskType::CRON_TASK_TYPE_DAILY, 'schedule_time' => '00:10']);
|
||||
App\Models\CronTask::create(['name' => 'NotifyTeam', 'execution_type' => CronTaskType::CRON_TASK_TYPE_DAILY, 'schedule_time' => '20:00']);
|
||||
```
|
||||
|
||||
Die Cron-Aufgaben entsprechen denen des bestehenden Servers (`BdpLvSachsenDataSeeder::installCronTasks()`). Sie gelten
|
||||
global und werden für jeden aktiven Tenant ausgeführt.
|
||||
|
||||
### 6.4 Erste Administratorin / ersten Administrator
|
||||
|
||||
1. Auf `https://<lv-subdomain>.meine-anmeldung.digital` ganz normal registrieren. Voraussetzung: Abschnitt 7 ist
|
||||
erledigt und der Mailversand funktioniert.
|
||||
2. Das Konto zur Administration hochstufen:
|
||||
|
||||
```bash
|
||||
mysql <datenbankname> -e "UPDATE users SET user_role_main='ROLE_ADMINISTRATOR', user_role_local_group='ROLE_GROUP_LEADER', active=1 WHERE email='<adresse>';"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Tenant-Domain in KeyHelp einrichten (je Tenant)
|
||||
|
||||
Diesen Ablauf für den Dach-Tenant `lv` und später für jede weitere Gruppe wiederholen.
|
||||
|
||||
### 7.1 Einmalig: Proxy-Module aktivieren (Apache)
|
||||
|
||||
```bash
|
||||
a2enmod proxy proxy_http headers
|
||||
systemctl reload apache2
|
||||
```
|
||||
|
||||
### 7.2 Domain anlegen
|
||||
|
||||
1. KeyHelp → **Domains** → neue Domain für das mareike-Konto, z. B. `wilde-moehre.meine-anmeldung.digital`.
|
||||
Den DNS-Eintrag (A/AAAA) auf endeavour setzen, falls KeyHelp den DNS nicht selbst verwaltet.
|
||||
2. **SSL/TLS:** Let's Encrypt aktivieren und **HTTPS erzwingen** einschalten.
|
||||
3. PHP wird für diese Domain nicht gebraucht.
|
||||
4. In den Domain-Einstellungen im Feld für **individuelle Webserver-Direktiven** (nur als Admin sichtbar, die
|
||||
Bezeichnung variiert je nach KeyHelp-Version) Folgendes eintragen:
|
||||
|
||||
**Apache** (KeyHelp-Standard):
|
||||
|
||||
```apache
|
||||
ProxyPreserveHost On
|
||||
ProxyRequests Off
|
||||
RequestHeader set X-Forwarded-Proto "https"
|
||||
ProxyPass /.well-known/acme-challenge/ !
|
||||
ProxyPass / http://127.0.0.1:8080/
|
||||
ProxyPassReverse / http://127.0.0.1:8080/
|
||||
```
|
||||
|
||||
**nginx** (falls KeyHelp auf nginx läuft):
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
client_max_body_size 70m;
|
||||
}
|
||||
```
|
||||
|
||||
Die Ausnahme für `/.well-known/acme-challenge/` sorgt dafür, dass KeyHelp die Zertifikate weiter selbst verlängern
|
||||
kann.
|
||||
5. Speichern und die Domain aufrufen. Solange der Tenant noch nicht existiert, zeigt mareike **404 „Tenant not
|
||||
found“**. Das ist ein gutes Zeichen, denn der Proxy funktioniert.
|
||||
|
||||
### 7.3 Tenant in mareike anlegen
|
||||
|
||||
Im Dach-Tenant `lv` unter der Tenant-Verwaltung den neuen Tenant anlegen. Als URL **exakt** die Domain ohne `https://`
|
||||
und ohne Schrägstrich eintragen, z. B. `wilde-moehre.meine-anmeldung.digital`.
|
||||
|
||||
Für den Tenant `lv` selbst ist das bereits in Schritt 6.3 passiert.
|
||||
|
||||
---
|
||||
|
||||
## 8. Cron einrichten
|
||||
|
||||
mareike hat keinen Laravel-Scheduler. Die Aufgaben laufen über die URL `/execute-crons`, die für **alle** aktiven
|
||||
Tenants arbeitet. Sie muss einmal pro Minute aufgerufen werden, dabei genügt der Host-Header eines beliebigen aktiven
|
||||
Tenants:
|
||||
|
||||
```bash
|
||||
cat > /etc/cron.d/mareike <<'EOF'
|
||||
* * * * * root curl -fsS -o /dev/null -H "Host: <lv-subdomain>.meine-anmeldung.digital" http://127.0.0.1:8080/execute-crons
|
||||
EOF
|
||||
```
|
||||
|
||||
Kontrolle: `/opt/mareike/storage/logs/` enthält die Task-Logs.
|
||||
|
||||
---
|
||||
|
||||
## 9. Updates ausrollen
|
||||
|
||||
```bash
|
||||
/opt/mareike/bin/deploy.sh 4.9.2 # Release-Tag
|
||||
/opt/mareike/bin/deploy.sh main # oder Branch
|
||||
```
|
||||
|
||||
Das Skript läuft in dieser Reihenfolge:
|
||||
|
||||
1. `git fetch` und Checkout des Standes.
|
||||
2. `storage/`-Ordner prüfen.
|
||||
3. Images neu bauen, dabei werden Basis-Images mit Sicherheitsupdates frisch gezogen.
|
||||
4. `migrate --force` ausführen.
|
||||
5. Container neu starten.
|
||||
6. Kompilierte Views leeren.
|
||||
7. Alte Images aufräumen.
|
||||
|
||||
Nützliche Kurzbefehle:
|
||||
|
||||
```bash
|
||||
/opt/mareike/bin/deploy.sh compose ps
|
||||
/opt/mareike/bin/deploy.sh compose logs -f web
|
||||
/opt/mareike/bin/deploy.sh artisan migrate:status
|
||||
/opt/mareike/bin/deploy.sh artisan tinker
|
||||
```
|
||||
|
||||
**Rollback:** Den vorherigen Tag ausrollen (`deploy.sh 4.9.1`). Migrationen werden dabei **nicht** zurückgedreht.
|
||||
Enthält ein Release Datenbankänderungen, deshalb vorher ein DB-Backup ziehen.
|
||||
|
||||
---
|
||||
|
||||
## 10. Backup
|
||||
|
||||
| Was | Wie |
|
||||
|---|---|
|
||||
| Datenbank | Über das KeyHelp-Backup, weil die DB von KeyHelp verwaltet wird. |
|
||||
| `/opt/mareike/storage` | **Nicht** im KeyHelp-Backup. Zusätzlich sichern, z. B. per borg/restic oder rsync auf das Backup-Ziel. |
|
||||
| `/opt/mareike/shared/.env` | Ebenfalls zusätzlich sichern, besonders wegen des `APP_KEY`. |
|
||||
| Code und Images | Nicht nötig, lassen sich jederzeit aus dem Repo neu bauen. |
|
||||
|
||||
---
|
||||
|
||||
## 11. Fehlersuche
|
||||
|
||||
| Symptom | Ursache / Lösung |
|
||||
|---|---|
|
||||
| 404 „Tenant not found“ | Der Host passt nicht zu `tenants.url` (Tippfehler, `https://` oder `/` im Eintrag), der Tenant ist nicht `has_active_instance`, oder der Proxy reicht den Host nicht durch (`ProxyPreserveHost On` fehlt). |
|
||||
| 502/503 vom KeyHelp-Webserver | Container laufen nicht (`deploy.sh compose ps`) oder die Firewall wurde neu geladen, dann hilft `systemctl restart docker`. |
|
||||
| Seite lädt ohne CSS, Mixed Content | `ASSET_URL` ist gesetzt oder die nginx-Config im Container wurde nicht übernommen (`fastcgi_param HTTPS on`). |
|
||||
| `SQLSTATE[HY000] [2002]` | Socket nicht erreichbar. `SELECT @@socket` prüfen, `MAREIKE_DB_SOCKET_DIR` bzw. `DB_SOCKET` anpassen, nach einem MariaDB-Neustart den Stack mit `deploy.sh compose restart app` neu starten. |
|
||||
| `Permission denied` in storage/ | Besitzer von `/opt/mareike/storage` muss `mareike-docker` sein, der nächste `deploy.sh`-Lauf korrigiert das. |
|
||||
| Upload bricht ab | Grenzen prüfen: `MAX_INVOICE_FILE_SIZE` in der `.env`, 64 MB in `php.ini`, 70 MB in `nginx.conf`. |
|
||||
| Mails kommen nicht an | `MAIL_*` prüfen, Log in `storage/logs/`. Absenderadresse muss zum Postfach passen (SPF/DKIM der Domain in KeyHelp). |
|
||||
|
||||
Logs:
|
||||
|
||||
```bash
|
||||
/opt/mareike/bin/deploy.sh compose logs --tail=200 app web
|
||||
ls -lt /opt/mareike/storage/logs/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Abgrenzung zum bestehenden Server
|
||||
|
||||
| | bestehender Server | endeavour |
|
||||
|---|---|---|
|
||||
| Dockerfile | `docker/prod.Dockerfile` | `docker/endeavour/Dockerfile` |
|
||||
| Compose | `docker-compose.prod` | `docker/endeavour/compose.yaml` |
|
||||
| nginx | `docker/nginx/default.conf` | `docker/endeavour/nginx.conf` |
|
||||
| Build-Kontext | `.dockerignore` | `docker/endeavour/Dockerfile.dockerignore` |
|
||||
| Datenbank | eigene | KeyHelp-MariaDB auf endeavour |
|
||||
| Tenants | `*.mareike.sachsen.pfadfinden.de` | `*.meine-anmeldung.digital` |
|
||||
|
||||
Beide Server nutzen denselben Code-Stand aus demselben Repository, haben aber **getrennte** Datenbanken, `.env`,
|
||||
WebDAV-Ordner und Tenants. Änderungen an `docker/endeavour/` wirken nur auf endeavour.
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Domains\CostUnit\Actions\ChangeCostUnitDetails\ChangeCostUnitDetailsCommand;
|
||||
use App\Domains\CostUnit\Actions\ChangeCostUnitDetails\ChangeCostUnitDetailsRequest;
|
||||
use App\Enumerations\CostUnitType;
|
||||
use App\Enumerations\UserRole;
|
||||
use App\Models\CostUnit;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Tasks\CloseCostUnit;
|
||||
use App\ValueObjects\Amount;
|
||||
use DateTime;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Der Abrechnungsschluss einer Kostenstelle.
|
||||
*
|
||||
* Er ist ein Tag, keine Uhrzeit: Abrechnungen dürfen bis einschließlich 23:59 des Stichtags eingereicht werden. Das
|
||||
* Modal „Details anpassen" muss ihn anzeigen -- sonst löscht jedes Speichern den Wert unbemerkt.
|
||||
*/
|
||||
class CostUnitBillingDeadlineTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private Tenant $tenant;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->tenant = Tenant::create([
|
||||
'slug' => 'wm',
|
||||
'name' => 'Wilde Möhre',
|
||||
'address_1' => 'Musterweg 1',
|
||||
'email' => 't@example.com',
|
||||
'email_finance' => 'finance@example.com',
|
||||
'url' => parse_url(config('app.url'), PHP_URL_HOST),
|
||||
'account_name' => 'Test e.V.',
|
||||
'account_iban' => 'DE00',
|
||||
'account_bic' => 'XY',
|
||||
'city' => 'Stadt',
|
||||
'postcode' => '00000',
|
||||
'invoice_prefix' => 'WM',
|
||||
'is_active_local_group' => true,
|
||||
'has_active_instance' => true,
|
||||
]);
|
||||
|
||||
app()->instance('tenant', $this->tenant);
|
||||
|
||||
DB::table('cost_unit_types')->insert(['slug' => CostUnitType::COST_UNIT_TYPE_EVENT, 'name' => 'Veranstaltung']);
|
||||
|
||||
foreach ([UserRole::USER_ROLE_ADMIN, UserRole::USER_ROLE_GROUP_LEADER, UserRole::USER_ROLE_USER] as $role) {
|
||||
UserRole::create(['slug' => $role, 'name' => $role]);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_details_liefern_den_abrechnungsschluss_im_format_des_datumsfelds(): void
|
||||
{
|
||||
$costUnit = $this->makeCostUnit('2026-09-11');
|
||||
$this->actingAs($this->makeAdmin());
|
||||
|
||||
$response = $this->getJson('/api/v1/cost-unit/' . $costUnit->id . '/details');
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertSame('2026-09-11', $response->json('costUnit.billingDeadline'));
|
||||
}
|
||||
|
||||
public function test_details_ohne_abrechnungsschluss_liefern_null(): void
|
||||
{
|
||||
$costUnit = $this->makeCostUnit(null);
|
||||
$this->actingAs($this->makeAdmin());
|
||||
|
||||
$response = $this->getJson('/api/v1/cost-unit/' . $costUnit->id . '/details');
|
||||
|
||||
$this->assertNull($response->json('costUnit.billingDeadline'));
|
||||
}
|
||||
|
||||
public function test_speichern_uebernimmt_das_datum_aus_dem_formular(): void
|
||||
{
|
||||
$costUnit = $this->makeCostUnit('2026-09-11');
|
||||
$this->actingAs($this->makeAdmin());
|
||||
|
||||
$this->postJson('/api/v1/cost-unit/' . $costUnit->id . '/details', ['formData' => [
|
||||
'billingDeadline' => '2026-09-30',
|
||||
'mailOnNew' => true,
|
||||
'distanceAllowance' => '0,30',
|
||||
]])->assertOk()->assertJson(['status' => 'success']);
|
||||
|
||||
$this->assertSame('2026-09-30', $costUnit->fresh()->billing_deadline);
|
||||
}
|
||||
|
||||
public function test_ein_geleertes_datumsfeld_entfernt_den_abrechnungsschluss_ohne_fehler(): void
|
||||
{
|
||||
$costUnit = $this->makeCostUnit('2026-09-11');
|
||||
$this->actingAs($this->makeAdmin());
|
||||
|
||||
$this->postJson('/api/v1/cost-unit/' . $costUnit->id . '/details', ['formData' => [
|
||||
'billingDeadline' => '',
|
||||
'mailOnNew' => false,
|
||||
'distanceAllowance' => '0,25',
|
||||
]])->assertOk()->assertJson(['status' => 'success']);
|
||||
|
||||
$this->assertNull($costUnit->fresh()->billing_deadline);
|
||||
}
|
||||
|
||||
public function test_eine_uhrzeit_im_request_wird_nicht_gespeichert(): void
|
||||
{
|
||||
$costUnit = $this->makeCostUnit(null);
|
||||
|
||||
new ChangeCostUnitDetailsCommand(new ChangeCostUnitDetailsRequest(
|
||||
$costUnit,
|
||||
Amount::fromString('0,25'),
|
||||
false,
|
||||
new DateTime('2026-09-11 14:23:45'),
|
||||
))->execute();
|
||||
|
||||
$this->assertSame('2026-09-11', $costUnit->fresh()->billing_deadline);
|
||||
}
|
||||
|
||||
public function test_am_stichtag_um_2359_ist_die_kostenstelle_noch_offen(): void
|
||||
{
|
||||
$costUnit = $this->makeCostUnit('2026-09-11');
|
||||
|
||||
$this->travelTo(new DateTime('2026-09-11 23:59:00'));
|
||||
new CloseCostUnit()->handle();
|
||||
|
||||
$this->assertTrue((bool) $costUnit->fresh()->allow_new);
|
||||
}
|
||||
|
||||
public function test_am_folgetag_schliesst_der_cron_die_kostenstelle(): void
|
||||
{
|
||||
$costUnit = $this->makeCostUnit('2026-09-11');
|
||||
|
||||
$this->travelTo(new DateTime('2026-09-12 00:05:00'));
|
||||
new CloseCostUnit()->handle();
|
||||
|
||||
$this->assertFalse((bool) $costUnit->fresh()->allow_new);
|
||||
}
|
||||
|
||||
public function test_ohne_abrechnungsschluss_setzt_der_cron_sechs_wochen_und_laesst_offen(): void
|
||||
{
|
||||
$costUnit = $this->makeCostUnit(null);
|
||||
|
||||
$this->travelTo(new DateTime('2026-09-12 00:05:00'));
|
||||
new CloseCostUnit()->handle();
|
||||
|
||||
$costUnit = $costUnit->fresh();
|
||||
$this->assertSame('2026-10-24', $costUnit->billing_deadline);
|
||||
$this->assertTrue((bool) $costUnit->allow_new);
|
||||
}
|
||||
|
||||
private function makeCostUnit(?string $billingDeadline): CostUnit
|
||||
{
|
||||
return CostUnit::create([
|
||||
'tenant' => $this->tenant->slug,
|
||||
'name' => 'Sommerlager',
|
||||
'type' => CostUnitType::COST_UNIT_TYPE_EVENT,
|
||||
'billing_deadline' => $billingDeadline,
|
||||
'distance_allowance' => 0.25,
|
||||
'mail_on_new' => false,
|
||||
'allow_new' => true,
|
||||
'archived' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
private function makeAdmin(): User
|
||||
{
|
||||
return User::create([
|
||||
'username' => 'admin-' . uniqid() . '@example.com',
|
||||
'email' => 'admin-' . uniqid() . '@example.com',
|
||||
'firstname' => 'Test',
|
||||
'lastname' => 'Person',
|
||||
'password' => bcrypt('secret'),
|
||||
'local_group' => $this->tenant->slug,
|
||||
'user_role_main' => UserRole::USER_ROLE_USER,
|
||||
'user_role_local_group' => UserRole::USER_ROLE_ADMIN,
|
||||
'active' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\Tenant;
|
||||
use App\Resources\EventResource;
|
||||
use App\Tasks\CloseEvent;
|
||||
use DateTime;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Anmeldeschluss und Early-Bird-Ende einer Veranstaltung.
|
||||
*
|
||||
* Beide sind ein Tag, keine Uhrzeit: Sie gelten einschließlich des ganzen Stichtags bis 23:59.
|
||||
*/
|
||||
class EventDeadlineTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private Tenant $tenant;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->tenant = Tenant::create([
|
||||
'slug' => 'tv',
|
||||
'name' => 'Test',
|
||||
'email' => 't@example.com',
|
||||
'email_finance' => 'finance@example.com',
|
||||
'url' => 'test.local',
|
||||
'account_name' => 'Test e.V.',
|
||||
'account_iban' => 'DE00',
|
||||
'account_bic' => 'XY',
|
||||
'city' => 'Stadt',
|
||||
'postcode' => '00000',
|
||||
'is_active_local_group' => true,
|
||||
'has_active_instance' => true,
|
||||
]);
|
||||
|
||||
app()->instance('tenant', $this->tenant);
|
||||
|
||||
DB::table('participation_fee_types')->insert(['slug' => 'fixed', 'name' => 'Fix']);
|
||||
}
|
||||
|
||||
public function test_am_anmeldeschluss_um_2359_bleibt_die_anmeldung_offen(): void
|
||||
{
|
||||
$event = $this->makeEvent();
|
||||
|
||||
$this->travelTo(new DateTime('2026-09-11 23:59:00'));
|
||||
new CloseEvent()->handle();
|
||||
|
||||
$this->assertTrue($event->fresh()->registration_allowed);
|
||||
}
|
||||
|
||||
public function test_am_folgetag_schliesst_der_cron_die_anmeldung(): void
|
||||
{
|
||||
$event = $this->makeEvent();
|
||||
|
||||
$this->travelTo(new DateTime('2026-09-12 00:10:00'));
|
||||
new CloseEvent()->handle();
|
||||
|
||||
$this->assertFalse($event->fresh()->registration_allowed);
|
||||
}
|
||||
|
||||
public function test_am_early_bird_stichtag_um_2359_gilt_der_fruehbucherpreis(): void
|
||||
{
|
||||
$event = $this->makeEvent();
|
||||
|
||||
$this->travelTo(new DateTime('2026-09-11 23:59:00'));
|
||||
|
||||
$this->assertEqualsWithDelta(1.0, new EventResource($event)->getMultiplier(), 0.0001);
|
||||
}
|
||||
|
||||
public function test_am_tag_nach_dem_early_bird_stichtag_gilt_der_aufschlag(): void
|
||||
{
|
||||
$event = $this->makeEvent();
|
||||
|
||||
$this->travelTo(new DateTime('2026-09-12 00:00:01'));
|
||||
|
||||
$this->assertEqualsWithDelta(1.2, new EventResource($event)->getMultiplier(), 0.0001);
|
||||
}
|
||||
|
||||
private function makeEvent(): Event
|
||||
{
|
||||
return Event::create([
|
||||
'tenant' => $this->tenant->slug,
|
||||
'name' => 'Event',
|
||||
'identifier' => 'evt-' . uniqid(),
|
||||
'location' => 'Ort',
|
||||
'postal_code' => '00000',
|
||||
'email' => 'e@example.com',
|
||||
'start_date' => '2026-09-20',
|
||||
'end_date' => '2026-09-25',
|
||||
'early_bird_end' => '2026-09-11',
|
||||
'registration_final_end' => '2026-09-11',
|
||||
'early_bird_end_amount_increase' => 20,
|
||||
'registration_allowed' => true,
|
||||
'account_owner' => 'Owner',
|
||||
'account_iban' => 'DE00',
|
||||
'participation_fee_type' => 'fixed',
|
||||
'pay_per_day' => false,
|
||||
'pay_direct' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -183,12 +183,47 @@ class InvoicePurposeTest extends TestCase
|
||||
|
||||
public function test_travel_costs_without_travellers_record_only_the_reason(): void
|
||||
{
|
||||
// Ohne Login steht im Formular niemand drin -- das darf den Beleg nicht aufhalten.
|
||||
// Der Command selbst verlangt keine Personen: Abrechnungskorrekturen laufen ohne sie durch. Die
|
||||
// Pflicht beim Einreichen prüft der SaveInvoiceController -- siehe die Tests unten.
|
||||
$invoice = $this->submit(InvoiceType::INVOICE_TYPE_TRAVELLING, travelReason: 'Landeslager');
|
||||
|
||||
$this->assertSame('Landeslager', $invoice->purpose);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Fahrtkostenabrechnung über das Formular, als Kilometerpauschale ohne Beleg.
|
||||
*/
|
||||
private function postTravelExpense(CostUnit $costUnit, ?string $travellers): \Illuminate\Testing\TestResponse
|
||||
{
|
||||
return $this->postJson('/api/v1/invoice/new/' . $costUnit->id . '/' . InvoiceType::INVOICE_TYPE_TRAVELLING, [
|
||||
'name' => 'Max Mustermann',
|
||||
'amount' => '100',
|
||||
'otherText' => 'Halle – Leipzig',
|
||||
'decision' => 'payout',
|
||||
'travelReason' => 'Landeslager',
|
||||
'travellers' => $travellers,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_submitting_travel_costs_requires_who_travelled(): void
|
||||
{
|
||||
$costUnit = $this->makeCostUnit();
|
||||
|
||||
foreach ([null, '', ' '] as $travellers) {
|
||||
$this->postTravelExpense($costUnit, $travellers)
|
||||
->assertJson(['status' => 'error', 'message' => 'Bitte gib an, welche Personen gereist sind.']);
|
||||
}
|
||||
|
||||
$this->assertSame(0, Invoice::count());
|
||||
}
|
||||
|
||||
public function test_submitting_travel_costs_with_travellers_is_accepted(): void
|
||||
{
|
||||
$this->postTravelExpense($this->makeCostUnit(), 'Mika und Kim')->assertJson(['status' => 'success']);
|
||||
|
||||
$this->assertSame('Landeslager — Mika und Kim', Invoice::sole()->purpose);
|
||||
}
|
||||
|
||||
public function test_a_refund_records_no_purpose(): void
|
||||
{
|
||||
// Beitragserstattungen entstehen ohne Freitext. `null` statt Leerstring: So greift für sie
|
||||
|
||||
Reference in new issue
Block a user