fast fertig

This commit is contained in:
2026-07-26 20:27:24 +02:00
parent 1370ad4ff7
commit c2fbb1b86f
19 changed files with 836 additions and 395 deletions
+5 -1
View File
@@ -30,8 +30,12 @@ yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
# env files
.env*
!.env.example
# runtime data
/data/
# vercel
.vercel
+50 -21
View File
@@ -1,36 +1,65 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# Familien-Motorrad-Tracker
## Getting Started
Gemeinsame Verwaltung von Motorrädern, Wartungen, TÜV-/Inspektionsterminen und Fahrten für eine Familie. Die App nutzt Next.js, TypeScript und SQLite und ist für den Betrieb per Docker hinter einem Reverse Proxy vorbereitet.
First, run the development server:
## Lokale Entwicklung
```bash
Voraussetzungen: Node.js 24 und npm.
```powershell
Copy-Item .env.example .env.local
npm ci
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
Vor dem Start in `.env.local` mindestens `ADMIN_PASSWORD`, `ADMIN_NAME` und `AUTH_SECRET` setzen. Die App ist anschließend unter `http://localhost:3000` erreichbar. Datenbank und Uploads liegen standardmäßig in `data\`.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
Beim ersten Start wird genau dann ein Admin aus den `ADMIN_*`-Werten angelegt, wenn noch kein aktives Konto existiert. Danach meldet man sich mit `ADMIN_NAME` und `ADMIN_PASSWORD` an. Das initiale Passwort sollte direkt unter **Konto** geändert werden.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Standard-Admin-Zugang (aus `.env.example`)
## Learn More
- Passwort: `change-this-password`
- Name: `Familien-Admin`
To learn more about Next.js, take a look at the following resources:
## Docker
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
```powershell
Copy-Item .env.example .env
# Sichere Werte in .env eintragen
docker compose up -d --build
```
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
Die Anwendung ist standardmäßig über Port 3000 erreichbar. `PORT` in `.env` ändert nur den veröffentlichten Host-Port. Für eine Domain sollte nginx, Traefik oder ein anderer Reverse Proxy TLS terminieren und auf diesen Port weiterleiten.
## Deploy on Vercel
Sichere Schlüssel lassen sich beispielsweise so erzeugen:
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
```powershell
node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"
node -e "console.log(require('node:crypto').randomBytes(32).toString('base64'))"
```
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
Den Hex-Wert für `AUTH_SECRET` und den Base64-Wert für `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY` verwenden.
## Benutzer und Rollen
Es gibt Administratoren und Mitglieder. Jedes Motorrad gehört genau einem Benutzer (Besitzer). Administratoren sehen alle Motorräder, Mitglieder sehen ihre eigenen plus explizit freigegebene Motorräder. Besitzer und Administratoren können Motorräder mit anderen aktiven Benutzern per Benutzername teilen. Konten werden durch Administratoren verwaltet. Motorräder dürfen vom Besitzer oder von Administratoren gelöscht werden. Gelöschte Benutzer können sich nicht mehr anmelden und sind nicht mehr als Fahrer auswählbar; vorhandene Wartungen und Fahrten bleiben mit dem ursprünglichen Namen zur Nachvollziehbarkeit erhalten. Der letzte aktive Administrator kann weder gelöscht noch herabgestuft werden.
## Backups
Alle persistenten Daten liegen im Docker-Volume `moped-data` unter `/app/data`:
- `moped-tracker.sqlite` sowie mögliche `-wal`-/`-shm`-Dateien
- `uploads\` mit Rechnungen und Fotos
Für ein konsistentes Backup den Container kurz stoppen und das komplette Volume sichern. Bei lokaler Entwicklung entsprechend den gesamten Ordner `data\` sichern.
## Qualität
```powershell
npm run lint
npm run build
```
## Sinnvolle nächste Ausbaustufen
E-Mail-Erinnerungen vor Fälligkeiten, Mehrsprachigkeit und PDF-Exporte der Wartungshistorie.
-282
View File
@@ -1,282 +0,0 @@
#
# There is insufficient memory for the Java Runtime Environment to continue.
# Native memory allocation (mmap) failed to map 266338304 bytes. Error detail: G1 virtual space
# Possible reasons:
# The system is out of physical RAM or swap space
# This process is running with CompressedOops enabled, and the Java Heap may be blocking the growth of the native heap
# Possible solutions:
# Reduce memory load on the system
# Increase physical memory or swap space
# Check if swap backing store is full
# Decrease Java heap size (-Xmx/-Xms)
# Decrease number of Java threads
# Decrease Java thread stack sizes (-Xss)
# Set larger code cache with -XX:ReservedCodeCacheSize=
# JVM is running with Zero Based Compressed Oops mode in which the Java heap is
# placed in the first 32GB address space. The Java Heap base address is the
# maximum limit for the native heap growth. Please use -XX:HeapBaseMinAddress
# to set the Java Heap base and to place the Java Heap above 32GB virtual address.
# This output file may be truncated or incomplete.
#
# Out of Memory Error (os_windows.cpp:3766), pid=444, tid=39064
#
# JRE version: (25.0.3+9) (build )
# Java VM: OpenJDK 64-Bit Server VM (25.0.3+9-b508.16, mixed mode, sharing, tiered, compressed oops, compressed class ptrs, g1 gc, windows-amd64)
# No core dump will be written. Minidumps are not enabled by default on client versions of Windows
#
--------------- S U M M A R Y ------------
Command Line: git4idea.http.GitAskPassApp Username for 'https://git.konfi-castle.com':
Host: Intel(R) Core(TM) i7-10700F CPU @ 2.90GHz, 16 cores, 15G, Windows 11 , 64 bit Build 26100 (10.0.26100.8875)
Time: Sun Jul 26 12:04:05 2026 Mitteleuropäische Sommerzeit elapsed time: 0.015396 seconds (0d 0h 0m 0s)
--------------- T H R E A D ---------------
Current thread (0x000001d94e30a300): JavaThread "Unknown thread" [_thread_in_vm, id=39064, stack(0x000000d2e4200000,0x000000d2e4300000) (1024K)]
Stack: [0x000000d2e4200000,0x000000d2e4300000]
Native frames: (J=compiled Java code, j=interpreted, Vv=VM code, C=native code)
V [jvm.dll+0x7c2949] (no source info available)
V [jvm.dll+0x78c01d] (no source info available)
V [jvm.dll+0xa105b3] (no source info available)
V [jvm.dll+0xa12dac] (no source info available)
V [jvm.dll+0xa134a3] (no source info available)
V [jvm.dll+0x308106] (no source info available)
V [jvm.dll+0x7beb55] (no source info available)
V [jvm.dll+0x7b1ae7] (no source info available)
V [jvm.dll+0x40224e] (no source info available)
V [jvm.dll+0x40a06b] (no source info available)
V [jvm.dll+0x3f68a9] (no source info available)
V [jvm.dll+0x3f6b5b] (no source info available)
V [jvm.dll+0x3ccb73] (no source info available)
V [jvm.dll+0x3cd7de] (no source info available)
V [jvm.dll+0x9d6907] (no source info available)
V [jvm.dll+0x467992] (no source info available)
V [jvm.dll+0x9ba039] (no source info available)
V [jvm.dll+0x509dd1] (no source info available)
V [jvm.dll+0x50b741] (no source info available)
C [jli.dll+0x5422] (no source info available)
C [ucrtbase.dll+0x2cd30] (no source info available)
C [KERNEL32.DLL+0x2e957] (no source info available)
C [ntdll.dll+0xaad6c] (no source info available)
Lock stack of current Java thread (top to bottom):
--------------- P R O C E S S ---------------
Threads class SMR info:
_java_thread_list=0x00007fffca8cc108, length=0, elements={
}
Java Threads: ( => current thread )
Total: 0
Other Threads:
0x000001d95085df00 WorkerThread "GC Thread#0" [id=17804, stack(0x000000d2e4300000,0x000000d2e4400000) (1024K)]
0x000001d968bd8dc0 ConcurrentGCThread "G1 Main Marker" [id=10620, stack(0x000000d2e4400000,0x000000d2e4500000) (1024K)]
0x000001d968bdd770 WorkerThread "G1 Conc#0" [id=46496, stack(0x000000d2e4500000,0x000000d2e4600000) (1024K)]
[error occurred during error reporting (printing all threads), id 0xc0000005, EXCEPTION_ACCESS_VIOLATION (0xc0000005) at pc=0x00007fffc9ee194a]
VM state: not at safepoint (not fully initialized)
VM Mutex/Monitor currently owned by a thread: ([mutex/lock_event])
[0x00007fffca954fb0] Heap_lock - owner thread: 0x000001d94e30a300
Heap address: 0x0000000702000000, size: 4064 MB, Compressed Oops mode: Zero based, Oop shift amount: 3
CDS archive(s) mapped at: [0x0000000000000000-0x0000000000000000-0x0000000000000000), size 0, SharedBaseAddress: 0x0000000800000000, ArchiveRelocationMode: 1.
UseCompressedClassPointers 1, UseCompactObjectHeaders 0
Narrow klass pointer bits 32, Max shift 3
Narrow klass base: 0xffffffffffffffff, Narrow klass shift: -1
Encoding Range: [0xffffffffffffffff - 0x000000007fffffff), (2147483648 bytes)
Klass Range: [0x0000000000000000 - 0x0000000000000000), (0 bytes)
Klass ID Range: [4294967295 - 0) (1)
No protection zone.
GC Precious Log:
CardTable entry size: 512
Card Set container configuration: InlinePtr #cards 4 size 8 Array Of Cards #cards 16 size 48 Howl #buckets 8 coarsen threshold 3686 Howl Bitmap #cards 512 size 80 coarsen threshold 460 Card regions per heap region 1 cards per card region 4096
Heap:
garbage-first heap total reserved 4161536K, committed 0K, used 0K [0x0000000702000000, 0x0000000800000000)
region size 2048K, 0 young (0K), 0 survivors (0K)
Heap Regions: E=young(eden), S=young(survivor), O=old, HS=humongous(starts), HC=humongous(continues), CS=collection set, F=free, TAMS=top-at-mark-start, PB=parsable bottom
Card table byte_map: [0x000001d964380000,0x000001d964b70000] _byte_map_base: 0x000001d960b70000
Marking Bits: (CMBitMap*) 0x000001d95085e540
Bits: [0x000001d964b70000, 0x000001d968af0000)
GC Heap Usage History (0 events):
No events
Metaspace Usage History (0 events):
No events
Dll operation events (1 events):
Event: 0.009 Loaded shared library C:\Users\Linus\AppData\Local\Programs\IntelliJ IDEA\jbr\bin\java.dll
Deoptimization events (0 events):
No events
Classes loaded (0 events):
No events
Classes unloaded (0 events):
No events
Classes redefined (0 events):
No events
Internal exceptions (0 events):
No events
VM Operations (0 events):
No events
Memory protections (0 events):
No events
Nmethod flushes (0 events):
No events
Events (0 events):
No events
Dynamic libraries:
0x00007ff729850000 - 0x00007ff72985a000 C:\Users\Linus\AppData\Local\Programs\IntelliJ IDEA\jbr\bin\java.exe
0x00007ff92cd20000 - 0x00007ff92cf86000 C:\WINDOWS\SYSTEM32\ntdll.dll
0x00007ff8b1760000 - 0x00007ff8b1781000 C:\Program Files\Norton\Suite\aswhook.dll
0x00007ff92b780000 - 0x00007ff92b849000 C:\WINDOWS\System32\KERNEL32.DLL
0x00007ff92a640000 - 0x00007ff92aa3e000 C:\WINDOWS\System32\KERNELBASE.dll
0x00007ff92a070000 - 0x00007ff92a1bc000 C:\WINDOWS\System32\ucrtbase.dll
0x00007fffdceb0000 - 0x00007fffdcec7000 C:\Users\Linus\AppData\Local\Programs\IntelliJ IDEA\jbr\bin\jli.dll
0x00007ff92c270000 - 0x00007ff92c437000 C:\WINDOWS\System32\USER32.dll
0x00007ff92a610000 - 0x00007ff92a637000 C:\WINDOWS\System32\win32u.dll
0x00007ff92c820000 - 0x00007ff92c84b000 C:\WINDOWS\System32\GDI32.dll
0x00007ff929f40000 - 0x00007ff92a06a000 C:\WINDOWS\System32\gdi32full.dll
0x00007ff92a1d0000 - 0x00007ff92a273000 C:\WINDOWS\System32\msvcp_win.dll
0x00007ff923ae0000 - 0x00007ff923afe000 C:\Users\Linus\AppData\Local\Programs\IntelliJ IDEA\jbr\bin\VCRUNTIME140.dll
0x00007ff907170000 - 0x00007ff907402000 C:\WINDOWS\WinSxS\amd64_microsoft.windows.common-controls_6595b64144ccf1df_6.0.26100.8875_none_3e0d5d42e32fe9dd\COMCTL32.dll
0x00007ff92abc0000 - 0x00007ff92ac69000 C:\WINDOWS\System32\msvcrt.dll
0x00007ff92bc10000 - 0x00007ff92bc42000 C:\WINDOWS\System32\IMM32.DLL
0x00007ff8d77b0000 - 0x00007ff8d790c000 C:\Program Files (x86)\Citrix\ICA Client\ctxapclient64.dll
0x00007ff92c950000 - 0x00007ff92ccd3000 C:\WINDOWS\System32\combase.dll
0x00007ff92ac70000 - 0x00007ff92ad88000 C:\WINDOWS\System32\RPCRT4.dll
0x00007ff923e20000 - 0x00007ff923e2b000 C:\WINDOWS\SYSTEM32\VERSION.dll
0x00007ff929e30000 - 0x00007ff929e5a000 C:\WINDOWS\SYSTEM32\bcrypt.dll
0x00007ff92bc70000 - 0x00007ff92bd1a000 C:\WINDOWS\System32\sechost.dll
0x00007ff92c760000 - 0x00007ff92c817000 C:\WINDOWS\System32\ADVAPI32.dll
0x00007ff928d10000 - 0x00007ff928d46000 C:\WINDOWS\SYSTEM32\ntmarta.dll
0x00007ff92a490000 - 0x00007ff92a53c000 C:\WINDOWS\System32\bcryptprimitives.dll
0x00007ff8ebad0000 - 0x00007ff8ebbbb000 C:\Program Files\Windhawk\Engine\1.5.1\64\windhawk.dll
0x00007ff921750000 - 0x00007ff921877000 C:\WINDOWS\SYSTEM32\WINHTTP.dll
0x00007ff923ad0000 - 0x00007ff923adc000 C:\Users\Linus\AppData\Local\Programs\IntelliJ IDEA\jbr\bin\vcruntime140_1.dll
0x00007ff910210000 - 0x00007ff910299000 C:\Users\Linus\AppData\Local\Programs\IntelliJ IDEA\jbr\bin\msvcp140.dll
0x00007fffc9b00000 - 0x00007fffcaa55000 C:\Users\Linus\AppData\Local\Programs\IntelliJ IDEA\jbr\bin\server\jvm.dll
0x00007ff92adc0000 - 0x00007ff92ae40000 C:\WINDOWS\System32\WS2_32.dll
0x00007ff929da0000 - 0x00007ff929dfe000 C:\WINDOWS\SYSTEM32\POWRPROF.dll
0x00007ff91b2f0000 - 0x00007ff91b326000 C:\WINDOWS\SYSTEM32\WINMM.dll
0x00007ff929d80000 - 0x00007ff929d94000 C:\WINDOWS\SYSTEM32\UMPDC.dll
0x00007ff928be0000 - 0x00007ff928bfb000 C:\WINDOWS\SYSTEM32\kernel.appcore.dll
0x00007ff923ac0000 - 0x00007ff923aca000 C:\Users\Linus\AppData\Local\Programs\IntelliJ IDEA\jbr\bin\jimage.dll
0x00007ff9271b0000 - 0x00007ff9273f2000 C:\WINDOWS\SYSTEM32\DBGHELP.DLL
0x000001d94fe80000 - 0x000001d94ff59000 C:\WINDOWS\System32\OLEAUT32.dll
0x00007ff923f70000 - 0x00007ff923fab000 C:\WINDOWS\SYSTEM32\dbgcore.DLL
0x00007ff923aa0000 - 0x00007ff923abf000 C:\Users\Linus\AppData\Local\Programs\IntelliJ IDEA\jbr\bin\java.dll
0x00007ff92b900000 - 0x00007ff92ba99000 C:\WINDOWS\System32\ole32.dll
0x00007ff92ae50000 - 0x00007ff92b5e2000 C:\WINDOWS\System32\SHELL32.dll
JVMTI agents: none
dbghelp: loaded successfully - version: 4.0.5 - missing functions: none
symbol engine: initialized successfully - sym options: 0x614 - pdb path: .;C:\Users\Linus\AppData\Local\Programs\IntelliJ IDEA\jbr\bin;C:\WINDOWS\SYSTEM32;C:\Program Files\Norton\Suite;C:\WINDOWS\WinSxS\amd64_microsoft.windows.common-controls_6595b64144ccf1df_6.0.26100.8875_none_3e0d5d42e32fe9dd;C:\Program Files (x86)\Citrix\ICA Client;C:\Program Files\Windhawk\Engine\1.5.1\64;C:\Users\Linus\AppData\Local\Programs\IntelliJ IDEA\jbr\bin\server
VM Arguments:
java_command: git4idea.http.GitAskPassApp Username for 'https://git.konfi-castle.com':
java_class_path (initial): C:/Users/Linus/AppData/Local/Programs/IntelliJ IDEA/plugins/vcs-git/lib/git4idea-rt.jar;C:/Users/Linus/AppData/Local/Programs/IntelliJ IDEA/lib/externalProcess-rt.jar
Launcher Type: SUN_STANDARD
[Global flags]
intx CICompilerCount = 12 {product} {ergonomic}
uint ConcGCThreads = 3 {product} {ergonomic}
uint G1ConcRefinementThreads = 13 {product} {ergonomic}
size_t G1HeapRegionSize = 2097152 {product} {ergonomic}
size_t InitialHeapSize = 266338304 {product} {ergonomic}
size_t MarkStackSize = 4194304 {product} {ergonomic}
size_t MarkStackSizeMax = 536870912 {product} {ergonomic}
size_t MaxHeapSize = 4261412864 {product} {ergonomic}
size_t MinHeapDeltaBytes = 2097152 {product} {ergonomic}
size_t MinHeapSize = 8388608 {product} {ergonomic}
uintx NonNMethodCodeHeapSize = 7667712 {pd product} {ergonomic}
uintx NonProfiledCodeHeapSize = 122028032 {pd product} {ergonomic}
uintx ProfiledCodeHeapSize = 122028032 {pd product} {ergonomic}
uintx ReservedCodeCacheSize = 251723776 {pd product} {ergonomic}
bool SegmentedCodeCache = true {product} {ergonomic}
size_t SoftMaxHeapSize = 4261412864 {manageable} {ergonomic}
bool UseCompressedOops = true {product lp64_product} {ergonomic}
bool UseG1GC = true {product} {ergonomic}
bool UseLargePagesIndividualAllocation = false {pd product} {ergonomic}
Logging:
Log output configuration:
#0: stdout all=warning uptime,level,tags foldmultilines=false
#1: stderr all=off uptime,level,tags foldmultilines=false
Release file:
<release file has not been read>
Environment Variables:
JAVA_HOME=C:\Program Files\Eclipse Adoptium\jdk-21.0.10.7-hotspot\
PATH=C:/Program Files/Git/mingw64/libexec/git-core;C:/Program Files/Git/mingw64/libexec/git-core;C:\Program Files\Git\mingw64\bin;C:\Program Files\Git\usr\bin;C:\Users\Linus\bin;C:\Program Files\Meta Horizon\Support\oculus-runtime;C:\Program Files\Eclipse Adoptium\jdk-21.0.10.7-hotspot\bin;C:\Program Files\Eclipse Adoptium\jdk-17.0.17.10-hotspot\bin;C:\Program Files\SqlCmd\;C:\Program Files\Common Files\Oracle\Java\javapath;C:\Program Files (x86)\Common Files\Oracle\Java\java8path;C:\Program Files (x86)\Common Files\Oracle\Java\javapath;C:\WINDOWS\system32;C:\WINDOWS;C:\WINDOWS\System32\Wbem;C:\WINDOWS\System32\WindowsPowerShell\v1.0\;C:\WINDOWS\System32\OpenSSH\;C:\Program Files\Git\cmd;C:\Program Files\PuTTY\;C:\Program Files\nodejs\;C:\ProgramData\chocolatey\bin;C:\Program Files\NVIDIA Corporation\NVIDIA app\NvDLISR;C:\Users\Linus\.config\herd\bin\nvm;C:\Program Files\nodejs;C:\Program Files\WireGuard\;C:\Program Files\dotnet\;C:\Program Files (x86)\NVIDIA Corporation\PhysX\Common;C:\Program Files\CMake\bin;C:\WINDOWS\system32;C:\WINDOWS;C:\WINDOWS\System32\Wbem;C:\WINDOWS\System32\WindowsPowerShell\v1.0\;C:\WINDOWS\System32\OpenSSH\;C:\Users\Linus\AppData\Local\Muse Hub\lib;C:\Program Files (x86)\Windows Kits\10\Windows Performance Toolkit\;C:\ProgramData\chocolatey\lib\maven\apache-maven-3.9.15\bin;C:\Program Files\Docker\Docker\resources\bin;C:\Program Files\PatchMon;C:\Users\Linus\AppData\Local\Muse Hub\lib;C:\Program Files\Yubico\YubiKey Manager CLI\;C:\Program Files\Netbird;C:\Program Files\Zabbix Agent\;C:\Program Files\Netbird\;C:\Program Files\PowerShell\7\;C:\Users\Linus\.local\bin;C:\Users\Linus\AppData\Local\Programs\Python\Python311\Scripts\;C:\Users\Linus\AppData\Local\Programs\Python\Python311\;C:\Users\Linus\AppData\Local\Programs\Python\Python314\Scripts;C:\Users\Linus\AppData\Local\Programs\Python\Python314\;C:\Users\Linus\.cargo\bin;C:\Users\Linus\.config\herd-lite\bin;C:\Users\Linus\AppData\Local\Microsoft\WindowsApps;C:\Users\Linus\AppData\Local\JetBrains\Toolbox\scripts;C:\Users\Linus\AppData\Local\GitHubDesktop\bin;C:\ProgramData\Linus\GitHubDesktop\bin;C:\Users\Linus\AppData\Roaming\npm;C:\Flutter\flutter\bin;C:\Users\Linus\AppData\Local\Programs\Azure Data Studio\bin;C:\Users\Linus\.dotnet\tools;C:\Program Files (x86)\Nmap;C:\gradle\gradle-9.2.0\bin;C:\Users\Linus\AppData\Local\Programs\Antigravity\bin;C:\Users\Linus\AppData\Local\Programs\Microsoft VS Code\bin;C:\Users\Linus\AppData\Local\Microsoft\WindowsApps;C:\Users\Linus\AppData\Local\Microsoft\WinGet\Links;C:\Users\Linus\AppData\Local\Microsoft\WinGet\Packages\Google.PlatformTools_Microsoft.Winget.Source_8wekyb3d8bbwe\platform-tools;C:\Users\Linus\AppData\Local\Programs\Ollama;C:\Program Files\JetBrains\DataGrip 2026.1\bin;C:\Users\Linus\AppData\Local\Programs\Warp\bin
USERNAME=Linus
DISPLAY=:0.0
LC_ALL=en_US.UTF-8
TERM=xterm-256color
TMPDIR=C:\Users\Linus\AppData\Local\Temp
OS=Windows_NT
PROCESSOR_IDENTIFIER=Intel64 Family 6 Model 165 Stepping 5, GenuineIntel
TMP=C:\Users\Linus\AppData\Local\Temp
TEMP=C:\Users\Linus\AppData\Local\Temp
Compilation memory statistics disabled.
Periodic native trim disabled
JNI global refs:
JNI global refs: 0, weak refs: 0
JNI global refs memory usage: 0, weak refs: 0
Process memory usage:
Resident Set Size: 17500K (0% of 16643176K total physical memory with 512524K free physical memory)
OOME stack traces (most recent first):
Classloader memory used:
--------------- S Y S T E M ---------------
OS:
Windows 11 , 64 bit Build 26100 (10.0.26100.8875)
OS uptime: 0 days 1:29 hours
Hyper-V role detected
CPU: total 16 (initial active 16) (8 cores per cpu, 2 threads per core) family 6 model 165 stepping 5 microcode 0xec, cx8, cmov, fxsr, ht, mmx, 3dnowpref, sse, sse2, sse3, ssse3, sse4.1, sse4.2, popcnt, lzcnt, tsc, tscinvbit, avx, avx2, aes, erms, clmul, bmi1, bmi2, adx, fma, vzeroupper, clflush, clflushopt, hv, rdtscp, f16c
Processor Information for the first 16 processors :
Max Mhz: 2904, Current Mhz: 2904, Mhz Limit: 2904
Memory: 4k page, system-wide physical 16253M (500M free)
TotalPageFile size 34846M (AvailPageFile size 112M)
current process WorkingSet (physical memory assigned to process): 17M, peak: 17M
current process commit charge ("private bytes"): 73M, peak: 326M
vm_info: OpenJDK 64-Bit Server VM (25.0.3+9-b508.16) for windows-amd64 JRE (25.0.3+9-b508.16), built on 2026-06-22 with MS VC++ 17.14 (VS2022)
END.
-1
View File
@@ -34,7 +34,6 @@ export default async function AccountPage({
<h3 className="panel-title">Profil</h3>
<dl className="data-list">
<div><dt>Name</dt><dd>{user.name}</dd></div>
<div><dt>E-Mail</dt><dd className="mono">{user.email}</dd></div>
<div><dt>Rolle</dt><dd>{user.role === "admin" ? "Administrator" : "Mitglied"}</dd></div>
</dl>
</div>
+3 -8
View File
@@ -15,7 +15,6 @@ export const runtime = "nodejs";
type UserRow = {
id: number;
name: string;
email: string;
role: UserRole;
created_at: string;
};
@@ -30,7 +29,9 @@ export default async function AdminUsersPage({
const params = await searchParams;
const users = db
.prepare("SELECT id, name, email, role, created_at FROM users ORDER BY name COLLATE NOCASE")
.prepare(
"SELECT id, name, role, created_at FROM users WHERE active = 1 ORDER BY name COLLATE NOCASE",
)
.all() as UserRow[];
const adminCount = users.filter((u) => u.role === "admin").length;
@@ -62,7 +63,6 @@ export default async function AdminUsersPage({
<thead>
<tr>
<th>Name</th>
<th>E-Mail</th>
<th>Rolle</th>
<th>Angelegt</th>
<th></th>
@@ -75,7 +75,6 @@ export default async function AdminUsersPage({
return (
<tr key={row.id}>
<td>{row.name}{isSelf && <span className="badge">Du</span>}</td>
<td className="mono">{row.email}</td>
<td>
<form action={changeUserRoleAction} className="inline-form">
<input type="hidden" name="id" value={row.id} />
@@ -121,10 +120,6 @@ export default async function AdminUsersPage({
Name *
<input name="name" required maxLength={100} />
</label>
<label>
E-Mail *
<input name="email" type="email" required maxLength={254} />
</label>
<label>
Passwort * (min. 10 Zeichen)
<input name="password" type="password" required minLength={10} maxLength={200} />
+39 -4
View File
@@ -5,6 +5,7 @@ import { MotorcycleForm } from "@/components/motorcycle-form";
import { requireSessionUser } from "@/lib/auth";
import { db, initDatabase } from "@/lib/db";
import { queryMessage } from "@/lib/format";
import { motorcycleAccessFilter } from "@/lib/motorcycle-access";
export const runtime = "nodejs";
@@ -17,6 +18,17 @@ type MotorcycleValue = {
vin: string | null;
plate: string | null;
current_km: number;
tire_pressure_front_bar: number | null;
tire_pressure_rear_bar: number | null;
chain_tension_min_mm: number | null;
chain_tension_max_mm: number | null;
oil_type: string | null;
oil_capacity_liters: number | null;
oil_check_temp: "warm" | "cold" | null;
oil_check_run_minutes: number | null;
oil_check_wait_min_minutes: number | null;
oil_check_wait_max_minutes: number | null;
oil_check_method: "dipstick" | "sight_glass" | null;
notes: string | null;
};
@@ -27,16 +39,39 @@ export default async function EditMotorcyclePage({
params: Promise<{ id: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
await requireSessionUser();
const user = await requireSessionUser();
initDatabase();
const { id } = await params;
const motorcycleId = Number(id);
if (!Number.isSafeInteger(motorcycleId) || motorcycleId < 1) notFound();
const visible = motorcycleAccessFilter(user);
const bike = db
.prepare("SELECT * FROM motorcycles WHERE id = ?")
.get(motorcycleId) as MotorcycleValue | undefined;
.prepare(`SELECT m.* FROM motorcycles m WHERE m.id = ? AND ${visible.clause}`)
.get(motorcycleId, ...visible.params) as MotorcycleValue | undefined;
if (!bike) notFound();
const motorcycleForForm: MotorcycleValue = {
id: bike.id,
nickname: bike.nickname,
brand: bike.brand,
model: bike.model,
year: bike.year,
vin: bike.vin,
plate: bike.plate,
current_km: bike.current_km,
tire_pressure_front_bar: bike.tire_pressure_front_bar,
tire_pressure_rear_bar: bike.tire_pressure_rear_bar,
chain_tension_min_mm: bike.chain_tension_min_mm,
chain_tension_max_mm: bike.chain_tension_max_mm,
oil_type: bike.oil_type,
oil_capacity_liters: bike.oil_capacity_liters,
oil_check_temp: bike.oil_check_temp,
oil_check_run_minutes: bike.oil_check_run_minutes,
oil_check_wait_min_minutes: bike.oil_check_wait_min_minutes,
oil_check_wait_max_minutes: bike.oil_check_wait_max_minutes,
oil_check_method: bike.oil_check_method,
notes: bike.notes,
};
const params_ = await searchParams;
@@ -56,7 +91,7 @@ export default async function EditMotorcyclePage({
</div>
</div>
<Flash error={queryMessage(params_, "error")} />
<MotorcycleForm motorcycle={bike} />
<MotorcycleForm motorcycle={motorcycleForForm} />
</>
);
}
+129 -11
View File
@@ -1,6 +1,12 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { deleteMaintenanceAction, deleteMotorcycleAction, deleteTripAction } from "@/app/actions/motorcycles";
import {
deleteMaintenanceAction,
deleteMotorcycleAction,
deleteTripAction,
revokeMotorcycleShareAction,
shareMotorcycleAction,
} from "@/app/actions/motorcycles";
import { Flash } from "@/components/flash";
import { InspectionSticker } from "@/components/inspection-sticker";
import { MaintenanceForm } from "@/components/maintenance-form";
@@ -9,6 +15,7 @@ import { TripForm } from "@/components/trip-form";
import { requireSessionUser } from "@/lib/auth";
import { db, initDatabase } from "@/lib/db";
import { formatDate, formatKm, formatMoney, queryMessage } from "@/lib/format";
import { canManageShares, motorcycleAccessFilter } from "@/lib/motorcycle-access";
import { getDueStatus } from "@/lib/status";
import { maintenanceLabels, type MaintenanceType } from "@/lib/types";
@@ -23,7 +30,20 @@ type MotorcycleRow = {
vin: string | null;
plate: string | null;
current_km: number;
created_by: number;
tire_pressure_front_bar: number | null;
tire_pressure_rear_bar: number | null;
chain_tension_min_mm: number | null;
chain_tension_max_mm: number | null;
oil_type: string | null;
oil_capacity_liters: number | null;
oil_check_temp: "warm" | "cold" | null;
oil_check_run_minutes: number | null;
oil_check_wait_min_minutes: number | null;
oil_check_wait_max_minutes: number | null;
oil_check_method: "dipstick" | "sight_glass" | null;
notes: string | null;
owner_name: string;
};
type EventRow = {
@@ -36,7 +56,7 @@ type EventRow = {
cost_cents: number | null;
due_date: string | null;
due_km: number | null;
creator: string | null;
creator: string;
attachment_id: number | null;
attachment_name: string | null;
};
@@ -44,13 +64,18 @@ type EventRow = {
type TripRow = {
id: number;
trip_date: string;
driver: string | null;
driver: string;
start_km: number;
end_km: number;
purpose: string | null;
notes: string | null;
};
type ShareRow = {
id: number;
name: string;
};
function latestDue(events: EventRow[], type: MaintenanceType) {
const match = events.find((event) => event.type === type && (event.due_date || event.due_km != null));
return { dueDate: match?.due_date ?? null, dueKm: match?.due_km ?? null };
@@ -69,17 +94,24 @@ export default async function MotorcycleDetailPage({
const motorcycleId = Number(id);
if (!Number.isSafeInteger(motorcycleId) || motorcycleId < 1) notFound();
const visible = motorcycleAccessFilter(user);
const bike = db
.prepare("SELECT * FROM motorcycles WHERE id = ?")
.get(motorcycleId) as MotorcycleRow | undefined;
.prepare(
`SELECT m.*, owner.name AS owner_name
FROM motorcycles m
JOIN users owner ON owner.id = m.created_by
WHERE m.id = ? AND ${visible.clause}`,
)
.get(motorcycleId, ...visible.params) as MotorcycleRow | undefined;
if (!bike) notFound();
const userCanManageShares = canManageShares(user, motorcycleId);
const params_ = await searchParams;
const events = db
.prepare(
`SELECT e.id, e.type, e.title, e.description, e.event_date, e.km, e.cost_cents,
e.due_date, e.due_km, u.name AS creator,
e.due_date, e.due_km, COALESCE(u.name, 'Gelöschter Benutzer') AS creator,
a.id AS attachment_id, a.original_name AS attachment_name
FROM maintenance_events e
LEFT JOIN users u ON u.id = e.created_by
@@ -91,7 +123,7 @@ export default async function MotorcycleDetailPage({
const trips = db
.prepare(
`SELECT t.id, t.trip_date, u.name AS driver, t.start_km, t.end_km, t.purpose, t.notes
`SELECT t.id, t.trip_date, COALESCE(u.name, 'Gelöschter Benutzer') AS driver, t.start_km, t.end_km, t.purpose, t.notes
FROM trips t
LEFT JOIN users u ON u.id = t.driver_user_id
WHERE t.motorcycle_id = ?
@@ -99,10 +131,38 @@ export default async function MotorcycleDetailPage({
)
.all(motorcycleId) as TripRow[];
const drivers = db.prepare("SELECT id, name FROM users ORDER BY name COLLATE NOCASE").all() as {
const drivers = db
.prepare("SELECT id, name FROM users WHERE active = 1 ORDER BY name COLLATE NOCASE")
.all() as {
id: number;
name: string;
}[];
const shares = db
.prepare(
`SELECT u.id, u.name
FROM motorcycle_shares ms
JOIN users u ON u.id = ms.user_id
WHERE ms.motorcycle_id = ? AND u.active = 1
ORDER BY u.name COLLATE NOCASE`,
)
.all(motorcycleId) as ShareRow[];
const shareCandidates = userCanManageShares
? (db
.prepare(
`SELECT u.id, u.name
FROM users u
WHERE u.active = 1
AND u.id != ?
AND u.id != ?
AND NOT EXISTS (
SELECT 1
FROM motorcycle_shares ms
WHERE ms.motorcycle_id = ? AND ms.user_id = u.id
)
ORDER BY u.name COLLATE NOCASE`,
)
.all(user.id, bike.created_by, motorcycleId) as ShareRow[])
: [];
const tuev = latestDue(events, "tuev_hu");
const inspection = latestDue(events, "inspektion");
@@ -126,8 +186,9 @@ export default async function MotorcycleDetailPage({
<p className="muted">{bike.plate ?? "Ohne Kennzeichen"}</p>
</div>
<div className="heading-actions">
<Link className="button" href={`/motorcycles/${bike.id}/maintenance`}>Wartungsansicht</Link>
<Link className="button" href={`/motorcycles/${bike.id}/edit`}>Bearbeiten</Link>
{user.role === "admin" && (
{(user.role === "admin" || bike.created_by === user.id) && (
<form
action={deleteMotorcycleAction}
className="inline-form"
@@ -170,11 +231,68 @@ export default async function MotorcycleDetailPage({
<div><dt>Baujahr</dt><dd>{bike.year ?? "—"}</dd></div>
<div><dt>Kennzeichen</dt><dd>{bike.plate ?? "—"}</dd></div>
<div><dt>FIN / VIN</dt><dd className="mono">{bike.vin ?? "—"}</dd></div>
<div><dt>Besitzer</dt><dd>{bike.owner_name}</dd></div>
</dl>
{bike.notes && <p className="notes">{bike.notes}</p>}
</div>
</section>
{userCanManageShares && (
<section className="section-block">
<div className="section-heading">
<div>
<p className="eyebrow">Freigaben</p>
<h2>Moped teilen</h2>
</div>
</div>
<div className="panel table-wrap">
{shares.length === 0 ? (
<p className="muted">Noch mit niemandem geteilt.</p>
) : (
<table className="data-table">
<thead>
<tr>
<th>Name</th>
<th></th>
</tr>
</thead>
<tbody>
{shares.map((share) => (
<tr key={share.id}>
<td>{share.name}</td>
<td>
<form action={revokeMotorcycleShareAction} className="inline-form">
<input type="hidden" name="motorcycle_id" value={bike.id} />
<input type="hidden" name="user_id" value={share.id} />
<button className="link-button danger" type="submit">Entfernen</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<form action={shareMotorcycleAction} className="panel inline-form">
<input type="hidden" name="motorcycle_id" value={bike.id} />
<label>
Benutzer hinzufügen
<select name="username" required defaultValue="">
<option value="" disabled>Bitte wählen</option>
{shareCandidates.map((candidate) => (
<option key={candidate.id} value={candidate.name}>
{candidate.name}
</option>
))}
</select>
</label>
<button className="button button-primary" type="submit" disabled={shareCandidates.length === 0}>
Teilen
</button>
</form>
</section>
)}
<section className="section-block">
<div className="section-heading">
<div>
@@ -201,7 +319,7 @@ export default async function MotorcycleDetailPage({
{(event.due_date || event.due_km != null) && (
<span>Nächste Fälligkeit: {formatDate(event.due_date)} · {formatKm(event.due_km)}</span>
)}
{event.creator && <span>von {event.creator}</span>}
<span>von {event.creator}</span>
{event.attachment_id && (
<a href={`/attachments/${event.attachment_id}`} target="_blank" rel="noreferrer">
📎 {event.attachment_name}
@@ -250,7 +368,7 @@ export default async function MotorcycleDetailPage({
{trips.map((trip) => (
<tr key={trip.id}>
<td>{formatDate(trip.trip_date)}</td>
<td>{trip.driver ?? "—"}</td>
<td>{trip.driver}</td>
<td className="mono">{formatKm(trip.start_km)}</td>
<td className="mono">{formatKm(trip.end_km)}</td>
<td className="mono">{formatKm(trip.end_km - trip.start_km)}</td>
+12 -5
View File
@@ -2,8 +2,10 @@ import Link from "next/link";
import { Flash } from "@/components/flash";
import { InspectionSticker } from "@/components/inspection-sticker";
import { StatusPill } from "@/components/status-pill";
import { requireSessionUser } from "@/lib/auth";
import { db, initDatabase } from "@/lib/db";
import { formatDate, formatKm, queryMessage } from "@/lib/format";
import { motorcycleAccessFilter } from "@/lib/motorcycle-access";
import { getDueStatus } from "@/lib/status";
import { maintenanceLabels, type MaintenanceType } from "@/lib/types";
@@ -39,8 +41,10 @@ export default async function Dashboard({
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const user = await requireSessionUser();
initDatabase();
const params = await searchParams;
const visible = motorcycleAccessFilter(user);
const motorcycles = db
.prepare(
`SELECT m.*,
@@ -60,16 +64,19 @@ export default async function Dashboard({
WHERE e.motorcycle_id = m.id AND e.type = 'inspektion'
AND (e.due_date IS NOT NULL OR e.due_km IS NOT NULL)
ORDER BY e.event_date DESC, e.id DESC LIMIT 1) AS inspection_due_km
FROM motorcycles m ORDER BY m.nickname COLLATE NOCASE`,
FROM motorcycles m
WHERE ${visible.clause}
ORDER BY m.nickname COLLATE NOCASE`,
)
.all() as BikeRow[];
.all(...visible.params) as BikeRow[];
const dueRows = db
.prepare(
`SELECT e.id, e.motorcycle_id, m.nickname, e.type, e.title,
e.due_date, e.due_km, m.current_km
FROM maintenance_events e
JOIN motorcycles m ON m.id = e.motorcycle_id
WHERE (e.due_date IS NOT NULL OR e.due_km IS NOT NULL)
WHERE ${visible.clause}
AND (e.due_date IS NOT NULL OR e.due_km IS NOT NULL)
AND e.id = (
SELECT newer.id FROM maintenance_events newer
WHERE newer.motorcycle_id = e.motorcycle_id
@@ -78,7 +85,7 @@ export default async function Dashboard({
ORDER BY newer.event_date DESC, newer.id DESC LIMIT 1
)`,
)
.all() as DueRow[];
.all(...visible.params) as DueRow[];
const dueItems = dueRows
.map((item) => ({
...item,
@@ -100,7 +107,7 @@ export default async function Dashboard({
<div>
<p className="eyebrow">Werkstattübersicht</p>
<h1>Unsere Motorräder</h1>
<p className="muted">{motorcycles.length} Maschinen im gemeinsamen Fuhrpark</p>
<p className="muted">{motorcycles.length} Maschinen in deinem Fuhrpark & Freigaben</p>
</div>
<Link className="button button-primary" href="/motorcycles/new">Motorrad anlegen</Link>
</div>
+5 -5
View File
@@ -9,24 +9,24 @@ import {
setSessionCookie,
} from "@/lib/auth";
import { db, initDatabase } from "@/lib/db";
import { emailValue, passwordValue } from "@/lib/validation";
import { passwordValue, text } from "@/lib/validation";
function messageUrl(path: string, kind: "error" | "success", message: string) {
return `${path}?${kind}=${encodeURIComponent(message)}`;
}
export async function loginAction(formData: FormData) {
let email: string;
let username: string;
let password: string;
try {
email = emailValue(formData);
username = text(formData, "username", { required: true, max: 100 });
password = String(formData.get("password") ?? "");
} catch (error) {
redirect(messageUrl("/login", "error", error instanceof Error ? error.message : "Ungültige Eingabe."));
}
const user = authenticateUser(email, password);
if (!user) redirect(messageUrl("/login", "error", "E-Mail oder Passwort ist falsch."));
const user = authenticateUser(username, password);
if (!user) redirect(messageUrl("/login", "error", "Benutzername oder Passwort ist falsch."));
await setSessionCookie(user);
redirect("/");
}
+228 -14
View File
@@ -4,11 +4,13 @@ import { randomUUID } from "node:crypto";
import { basename, join } from "node:path";
import { existsSync, unlinkSync, writeFileSync } from "node:fs";
import { redirect, unstable_rethrow } from "next/navigation";
import { requireAdminUser, requireSessionUser } from "@/lib/auth";
import { requireSessionUser } from "@/lib/auth";
import { db, initDatabase, transaction } from "@/lib/db";
import { getUploadDir } from "@/lib/env";
import { canManageShares, ensureMotorcycleAccess } from "@/lib/motorcycle-access";
import {
dateValue,
decimal,
idValue,
integer,
maintenanceType,
@@ -31,11 +33,81 @@ function errorMessage(error: unknown) {
return error instanceof Error ? error.message : "Die Aktion konnte nicht ausgeführt werden.";
}
function motorcycleExists(id: number) {
return Boolean(db.prepare("SELECT 1 FROM motorcycles WHERE id = ?").get(id));
function oilCheckFields(formData: FormData) {
const oilCheckTempRaw = text(formData, "oil_check_temp", { max: 20 });
const oilCheckMethodRaw = text(formData, "oil_check_method", { max: 20 });
const oilCheckTemp =
oilCheckTempRaw === "warm" || oilCheckTempRaw === "cold" ? oilCheckTempRaw : null;
const oilCheckMethod =
oilCheckMethodRaw === "dipstick" || oilCheckMethodRaw === "sight_glass"
? oilCheckMethodRaw
: null;
if (oilCheckTempRaw && !oilCheckTemp) throw new Error("Ölprüfung: Ungültige Temperatur-Angabe.");
if (oilCheckMethodRaw && !oilCheckMethod) throw new Error("Ölprüfung: Ungültige Messmethode.");
const oilCheckRunMinutes = integer(formData, "oil_check_run_minutes", { min: 0, max: 120 });
const oilCheckWaitMinMinutes = integer(formData, "oil_check_wait_min_minutes", { min: 0, max: 120 });
const oilCheckWaitMaxMinutes = integer(formData, "oil_check_wait_max_minutes", { min: 0, max: 120 });
if (oilCheckTemp !== "warm") {
if (oilCheckRunMinutes != null || oilCheckWaitMinMinutes != null || oilCheckWaitMaxMinutes != null) {
throw new Error("Lauf- und Wartezeiten beim Ölstand sind nur bei warmem Motor zulässig.");
}
} else {
if ((oilCheckWaitMinMinutes == null) !== (oilCheckWaitMaxMinutes == null)) {
throw new Error("Bitte Min- und Max-Wartezeit gemeinsam angeben.");
}
if (
oilCheckWaitMinMinutes != null &&
oilCheckWaitMaxMinutes != null &&
oilCheckWaitMinMinutes > oilCheckWaitMaxMinutes
) {
throw new Error("Die Min-Wartezeit darf nicht größer als die Max-Wartezeit sein.");
}
}
return {
oilCheckTemp,
oilCheckRunMinutes,
oilCheckWaitMinMinutes,
oilCheckWaitMaxMinutes,
oilCheckMethod,
};
}
function maintenanceSpecsFields(formData: FormData) {
const oilType = text(formData, "oil_type", { max: 80 }) || null;
const oilCapacityLiters = decimal(formData, "oil_capacity_liters", { min: 0.1, max: 20 });
const oilChecks = oilCheckFields(formData);
const chainTensionMinMm = integer(formData, "chain_tension_min_mm", { min: 1, max: 100 });
const chainTensionMaxMm = integer(formData, "chain_tension_max_mm", { min: 1, max: 100 });
if ((chainTensionMinMm == null) !== (chainTensionMaxMm == null)) {
throw new Error("Bitte Kettenspannung von/bis gemeinsam angeben.");
}
if (chainTensionMinMm != null && chainTensionMaxMm != null && chainTensionMinMm > chainTensionMaxMm) {
throw new Error("Kettenspannung: Von darf nicht größer als Bis sein.");
}
if ((oilType == null) !== (oilCapacityLiters == null)) {
throw new Error("Bitte Öltyp und Ölmenge zusammen angeben.");
}
if ((oilChecks.oilCheckTemp == null) !== (oilChecks.oilCheckMethod == null)) {
throw new Error("Bitte bei der Ölprüfung Temperatur und Methode zusammen angeben.");
}
return {
tirePressureFrontBar: decimal(formData, "tire_pressure_front_bar", { min: 0.5, max: 6 }),
tirePressureRearBar: decimal(formData, "tire_pressure_rear_bar", { min: 0.5, max: 6 }),
chainTensionMinMm,
chainTensionMaxMm,
oilType,
oilCapacityLiters,
...oilChecks,
};
}
function motorcycleFields(formData: FormData) {
const maintenanceSpecs = maintenanceSpecsFields(formData);
return {
nickname: text(formData, "nickname", { required: true, max: 80 }),
brand: text(formData, "brand", { required: true, max: 80 }),
@@ -44,10 +116,50 @@ function motorcycleFields(formData: FormData) {
vin: text(formData, "vin", { max: 50 }) || null,
plate: text(formData, "plate", { max: 30 }) || null,
currentKm: integer(formData, "current_km", { required: true, min: 0, max: 10_000_000 }) ?? 0,
...maintenanceSpecs,
notes: text(formData, "notes", { max: 5000 }) || null,
};
}
export async function updateMotorcycleMaintenanceAction(formData: FormData) {
const user = await requireSessionUser();
initDatabase();
let id = 0;
try {
id = idValue(formData);
ensureMotorcycleAccess(user, id);
const value = maintenanceSpecsFields(formData);
const result = db
.prepare(
`UPDATE motorcycles
SET tire_pressure_front_bar = ?, tire_pressure_rear_bar = ?,
chain_tension_min_mm = ?, chain_tension_max_mm = ?,
oil_type = ?, oil_capacity_liters = ?,
oil_check_temp = ?, oil_check_run_minutes = ?, oil_check_wait_min_minutes = ?, oil_check_wait_max_minutes = ?, oil_check_method = ?
WHERE id = ?`,
)
.run(
value.tirePressureFrontBar,
value.tirePressureRearBar,
value.chainTensionMinMm,
value.chainTensionMaxMm,
value.oilType,
value.oilCapacityLiters,
value.oilCheckTemp,
value.oilCheckRunMinutes,
value.oilCheckWaitMinMinutes,
value.oilCheckWaitMaxMinutes,
value.oilCheckMethod,
id,
);
if (result.changes === 0) throw new Error("Motorrad wurde nicht gefunden.");
redirect(`/motorcycles/${id}/maintenance?success=${encodeURIComponent("Wartungswerte gespeichert.")}`);
} catch (error) {
unstable_rethrow(error);
go(id ? `/motorcycles/${id}/maintenance/edit` : "/", "error", errorMessage(error));
}
}
export async function createMotorcycleAction(formData: FormData) {
const user = await requireSessionUser();
initDatabase();
@@ -56,8 +168,12 @@ export async function createMotorcycleAction(formData: FormData) {
const result = db
.prepare(
`INSERT INTO motorcycles
(nickname, brand, model, year, vin, plate, current_km, notes, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
(nickname, brand, model, year, vin, plate, current_km,
tire_pressure_front_bar, tire_pressure_rear_bar, chain_tension_min_mm, chain_tension_max_mm,
oil_type, oil_capacity_liters,
oil_check_temp, oil_check_run_minutes, oil_check_wait_min_minutes, oil_check_wait_max_minutes, oil_check_method,
notes, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ,
)
.run(
value.nickname,
@@ -67,6 +183,17 @@ export async function createMotorcycleAction(formData: FormData) {
value.vin,
value.plate,
value.currentKm,
value.tirePressureFrontBar,
value.tirePressureRearBar,
value.chainTensionMinMm,
value.chainTensionMaxMm,
value.oilType,
value.oilCapacityLiters,
value.oilCheckTemp,
value.oilCheckRunMinutes,
value.oilCheckWaitMinMinutes,
value.oilCheckWaitMaxMinutes,
value.oilCheckMethod,
value.notes,
user.id,
);
@@ -78,17 +205,23 @@ export async function createMotorcycleAction(formData: FormData) {
}
export async function updateMotorcycleAction(formData: FormData) {
await requireSessionUser();
const user = await requireSessionUser();
initDatabase();
let id = 0;
try {
id = idValue(formData);
ensureMotorcycleAccess(user, id);
const value = motorcycleFields(formData);
const result = db
.prepare(
`UPDATE motorcycles
SET nickname = ?, brand = ?, model = ?, year = ?, vin = ?, plate = ?,
current_km = ?, notes = ?
current_km = ?,
tire_pressure_front_bar = ?, tire_pressure_rear_bar = ?,
chain_tension_min_mm = ?, chain_tension_max_mm = ?,
oil_type = ?, oil_capacity_liters = ?,
oil_check_temp = ?, oil_check_run_minutes = ?, oil_check_wait_min_minutes = ?, oil_check_wait_max_minutes = ?, oil_check_method = ?,
notes = ?
WHERE id = ?`,
)
.run(
@@ -99,6 +232,17 @@ export async function updateMotorcycleAction(formData: FormData) {
value.vin,
value.plate,
value.currentKm,
value.tirePressureFrontBar,
value.tirePressureRearBar,
value.chainTensionMinMm,
value.chainTensionMaxMm,
value.oilType,
value.oilCapacityLiters,
value.oilCheckTemp,
value.oilCheckRunMinutes,
value.oilCheckWaitMinMinutes,
value.oilCheckWaitMaxMinutes,
value.oilCheckMethod,
value.notes,
id,
);
@@ -111,10 +255,17 @@ export async function updateMotorcycleAction(formData: FormData) {
}
export async function deleteMotorcycleAction(formData: FormData) {
await requireAdminUser();
const user = await requireSessionUser();
initDatabase();
try {
const id = idValue(formData);
const owner = db
.prepare("SELECT created_by FROM motorcycles WHERE id = ?")
.get(id) as { created_by: number } | undefined;
if (!owner) throw new Error("Motorrad wurde nicht gefunden.");
if (user.role !== "admin" && owner.created_by !== user.id) {
throw new Error("Du darfst dieses Moped nicht löschen.");
}
const files = db
.prepare(
`SELECT a.stored_name
@@ -163,7 +314,7 @@ export async function createMaintenanceAction(formData: FormData) {
try {
motorcycleId = idValue(formData, "motorcycle_id");
if (!motorcycleExists(motorcycleId)) throw new Error("Motorrad wurde nicht gefunden.");
ensureMotorcycleAccess(user, motorcycleId);
const type = maintenanceType(formData);
const title = text(formData, "title", { required: true, max: 120 });
const description = text(formData, "description", { max: 5000 }) || null;
@@ -245,7 +396,7 @@ export async function createMaintenanceAction(formData: FormData) {
}
export async function deleteMaintenanceAction(formData: FormData) {
await requireSessionUser();
const user = await requireSessionUser();
initDatabase();
let motorcycleId = 0;
try {
@@ -260,6 +411,7 @@ export async function deleteMaintenanceAction(formData: FormData) {
.get(id) as { motorcycle_id: number; stored_name: string | null } | undefined;
if (!row) throw new Error("Wartungseintrag wurde nicht gefunden.");
motorcycleId = row.motorcycle_id;
ensureMotorcycleAccess(user, motorcycleId);
db.prepare("DELETE FROM maintenance_events WHERE id = ?").run(id);
if (row.stored_name) {
const path = join(getUploadDir(), basename(row.stored_name));
@@ -273,17 +425,20 @@ export async function deleteMaintenanceAction(formData: FormData) {
}
export async function createTripAction(formData: FormData) {
await requireSessionUser();
const user = await requireSessionUser();
initDatabase();
let motorcycleId = 0;
try {
motorcycleId = idValue(formData, "motorcycle_id");
if (!motorcycleExists(motorcycleId)) throw new Error("Motorrad wurde nicht gefunden.");
ensureMotorcycleAccess(user, motorcycleId);
const tripDate = dateValue(formData, "trip_date", true);
const driverUserId = integer(formData, "driver_user_id", { required: true, min: 1 });
const startKm = integer(formData, "start_km", { required: true, min: 0, max: 10_000_000 });
const endKm = integer(formData, "end_km", { required: true, min: 0, max: 10_000_000 });
if (driverUserId == null || !db.prepare("SELECT 1 FROM users WHERE id = ?").get(driverUserId)) {
if (
driverUserId == null ||
!db.prepare("SELECT 1 FROM users WHERE id = ? AND active = 1").get(driverUserId)
) {
throw new Error("Fahrer wurde nicht gefunden.");
}
if (startKm == null || endKm == null || endKm < startKm) {
@@ -310,7 +465,7 @@ export async function createTripAction(formData: FormData) {
}
export async function deleteTripAction(formData: FormData) {
await requireSessionUser();
const user = await requireSessionUser();
initDatabase();
let motorcycleId = 0;
try {
@@ -320,6 +475,7 @@ export async function deleteTripAction(formData: FormData) {
| undefined;
if (!row) throw new Error("Fahrt wurde nicht gefunden.");
motorcycleId = row.motorcycle_id;
ensureMotorcycleAccess(user, motorcycleId);
db.prepare("DELETE FROM trips WHERE id = ?").run(id);
redirect(`/motorcycles/${motorcycleId}?success=${encodeURIComponent("Fahrt gelöscht.")}`);
} catch (error) {
@@ -327,3 +483,61 @@ export async function deleteTripAction(formData: FormData) {
go(motorcycleId ? `/motorcycles/${motorcycleId}` : "/", "error", errorMessage(error));
}
}
export async function shareMotorcycleAction(formData: FormData) {
const user = await requireSessionUser();
initDatabase();
let motorcycleId = 0;
try {
motorcycleId = idValue(formData, "motorcycle_id");
const username = text(formData, "username", { required: true, max: 100 });
if (!canManageShares(user, motorcycleId)) {
throw new Error("Du darfst die Freigaben dieses Mopeds nicht verwalten.");
}
const target = db
.prepare("SELECT id, active FROM users WHERE name = ? COLLATE NOCASE")
.all(username) as { id: number; active: number }[];
if (target.length !== 1 || target[0].active !== 1) throw new Error("Benutzer wurde nicht gefunden.");
const targetUserId = target[0].id;
const owner = db
.prepare("SELECT created_by FROM motorcycles WHERE id = ?")
.get(motorcycleId) as { created_by: number } | undefined;
if (!owner) throw new Error("Motorrad wurde nicht gefunden.");
if (owner.created_by === targetUserId) throw new Error("Der Besitzer hat bereits Zugriff.");
db.prepare(
`INSERT OR IGNORE INTO motorcycle_shares (motorcycle_id, user_id, shared_by)
VALUES (?, ?, ?)`,
).run(motorcycleId, targetUserId, user.id);
redirect(`/motorcycles/${motorcycleId}?success=${encodeURIComponent("Freigabe gespeichert.")}`);
} catch (error) {
unstable_rethrow(error);
go(motorcycleId ? `/motorcycles/${motorcycleId}` : "/", "error", errorMessage(error));
}
}
export async function revokeMotorcycleShareAction(formData: FormData) {
const user = await requireSessionUser();
initDatabase();
let motorcycleId = 0;
try {
motorcycleId = idValue(formData, "motorcycle_id");
const targetUserId = integer(formData, "user_id", { required: true, min: 1 });
if (targetUserId == null) throw new Error("Benutzer wurde nicht gefunden.");
if (!canManageShares(user, motorcycleId)) {
throw new Error("Du darfst die Freigaben dieses Mopeds nicht verwalten.");
}
db.prepare("DELETE FROM motorcycle_shares WHERE motorcycle_id = ? AND user_id = ?").run(
motorcycleId,
targetUserId,
);
redirect(`/motorcycles/${motorcycleId}?success=${encodeURIComponent("Freigabe entfernt.")}`);
} catch (error) {
unstable_rethrow(error);
go(motorcycleId ? `/motorcycles/${motorcycleId}` : "/", "error", errorMessage(error));
}
}
+31 -22
View File
@@ -1,10 +1,11 @@
"use server";
import { randomUUID } from "node:crypto";
import { hashSync } from "bcryptjs";
import { redirect, unstable_rethrow } from "next/navigation";
import { requireAdminUser } from "@/lib/auth";
import { db, initDatabase, transaction } from "@/lib/db";
import { emailValue, idValue, passwordValue, roleValue, text } from "@/lib/validation";
import { idValue, passwordValue, roleValue, text } from "@/lib/validation";
import type { UserRole } from "@/lib/types";
function go(kind: "error" | "success", message: string): never {
@@ -13,7 +14,7 @@ function go(kind: "error" | "success", message: string): never {
function errorMessage(error: unknown) {
if (error instanceof Error && error.message.includes("UNIQUE constraint failed")) {
return "Diese E-Mail-Adresse ist bereits vergeben.";
return "Dieser Benutzername ist bereits vergeben.";
}
return error instanceof Error ? error.message : "Die Aktion konnte nicht ausgeführt werden.";
}
@@ -23,12 +24,16 @@ export async function createUserAction(formData: FormData) {
initDatabase();
try {
const name = text(formData, "name", { required: true, max: 100 });
const email = emailValue(formData);
const existing = db
.prepare("SELECT 1 FROM users WHERE name = ? COLLATE NOCASE AND active = 1")
.get(name);
if (existing) throw new Error("Dieser Benutzername ist bereits vergeben.");
const password = passwordValue(formData);
const role = roleValue(formData);
const internalEmail = `user-${name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "konto"}-${randomUUID().slice(0, 8)}@local.invalid`;
db.prepare(
"INSERT INTO users (name, email, password_hash, role) VALUES (?, ?, ?, ?)",
).run(name, email, hashSync(password, 12), role);
).run(name, internalEmail, hashSync(password, 12), role);
go("success", "Benutzer wurde angelegt.");
} catch (error) {
unstable_rethrow(error);
@@ -43,20 +48,20 @@ export async function changeUserRoleAction(formData: FormData) {
const id = idValue(formData);
const role = roleValue(formData);
transaction(() => {
const target = db.prepare("SELECT role FROM users WHERE id = ?").get(id) as
const target = db.prepare("SELECT role FROM users WHERE id = ? AND active = 1").get(id) as
| { role: UserRole }
| undefined;
if (!target) throw new Error("Benutzer wurde nicht gefunden.");
if (target.role === "admin" && role === "member") {
const count = db.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'admin'").get() as {
count: number;
};
const count = db
.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'admin' AND active = 1")
.get() as { count: number };
if (count.count <= 1) throw new Error("Der letzte Administrator kann nicht herabgestuft werden.");
if (id === currentUser.id) {
throw new Error("Die eigene Administratorrolle kann nicht herabgestuft werden.");
}
}
db.prepare("UPDATE users SET role = ? WHERE id = ?").run(role, id);
db.prepare("UPDATE users SET role = ? WHERE id = ? AND active = 1").run(role, id);
});
go("success", "Rolle wurde geändert.");
} catch (error) {
@@ -72,26 +77,30 @@ export async function deleteUserAction(formData: FormData) {
const id = idValue(formData);
if (id === currentUser.id) throw new Error("Das aktuell angemeldete Konto kann nicht gelöscht werden.");
transaction(() => {
const target = db.prepare("SELECT role FROM users WHERE id = ?").get(id) as
const target = db
.prepare("SELECT role FROM users WHERE id = ? AND active = 1")
.get(id) as
| { role: UserRole }
| undefined;
if (!target) throw new Error("Benutzer wurde nicht gefunden.");
if (target.role === "admin") {
const count = db.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'admin'").get() as {
count: number;
};
const count = db
.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'admin' AND active = 1")
.get() as { count: number };
if (count.count <= 1) throw new Error("Der letzte Administrator kann nicht gelöscht werden.");
}
try {
db.prepare("DELETE FROM users WHERE id = ?").run(id);
} catch (error) {
if (error instanceof Error && error.message.includes("FOREIGN KEY")) {
throw new Error("Benutzer mit vorhandenen Einträgen kann nicht gelöscht werden.");
}
throw error;
}
db.prepare(
`UPDATE users
SET name = name || ' (gelöscht)',
email = ?,
password_hash = ?,
role = 'member',
active = 0,
deleted_at = CURRENT_TIMESTAMP
WHERE id = ? AND active = 1`,
).run(`deleted-${id}-${randomUUID()}@invalid.local`, hashSync(randomUUID(), 12), id);
});
go("success", "Benutzer wurde gelöscht.");
go("success", "Benutzerkonto wurde gelöscht; vorhandene Einträge bleiben erhalten.");
} catch (error) {
unstable_rethrow(error);
go("error", errorMessage(error));
+10 -2
View File
@@ -4,6 +4,7 @@ import type { NextRequest } from "next/server";
import { getSessionUser } from "@/lib/auth";
import { db, initDatabase } from "@/lib/db";
import { getUploadDir } from "@/lib/env";
import { motorcycleAccessFilter } from "@/lib/motorcycle-access";
export const runtime = "nodejs";
@@ -26,9 +27,16 @@ export async function GET(_request: NextRequest, ctx: RouteContext<"/attachments
}
initDatabase();
const access = motorcycleAccessFilter(user, "m");
const row = db
.prepare("SELECT stored_name, original_name, mime_type FROM attachments WHERE id = ?")
.get(attachmentId) as AttachmentRow | undefined;
.prepare(
`SELECT a.stored_name, a.original_name, a.mime_type
FROM attachments a
JOIN maintenance_events e ON e.id = a.maintenance_event_id
JOIN motorcycles m ON m.id = e.motorcycle_id
WHERE a.id = ? AND ${access.clause}`,
)
.get(attachmentId, ...access.params) as AttachmentRow | undefined;
if (!row) {
return new Response("Anhang wurde nicht gefunden.", { status: 404 });
}
+2 -2
View File
@@ -34,8 +34,8 @@ export default async function LoginPage({
<Flash error={queryMessage(params, "error")} />
<form action={loginAction} className="stack-form">
<label>
E-Mail
<input name="email" type="email" autoComplete="email" required autoFocus />
Benutzername
<input name="username" autoComplete="username" required autoFocus />
</label>
<label>
Passwort
+1 -1
View File
@@ -7,7 +7,7 @@ function today() {
export function MaintenanceForm({ motorcycleId }: { motorcycleId: number }) {
return (
<form action={createMaintenanceAction} className="panel form-grid" encType="multipart/form-data">
<form action={createMaintenanceAction} className="panel form-grid">
<input type="hidden" name="motorcycle_id" value={motorcycleId} />
<label>
Art *
+147
View File
@@ -1,3 +1,6 @@
"use client";
import { useState } from "react";
import { createMotorcycleAction, updateMotorcycleAction } from "@/app/actions/motorcycles";
type MotorcycleValue = {
@@ -9,10 +12,25 @@ type MotorcycleValue = {
vin: string | null;
plate: string | null;
current_km: number;
tire_pressure_front_bar: number | null;
tire_pressure_rear_bar: number | null;
chain_tension_min_mm: number | null;
chain_tension_max_mm: number | null;
oil_type: string | null;
oil_capacity_liters: number | null;
oil_check_temp: "warm" | "cold" | null;
oil_check_run_minutes: number | null;
oil_check_wait_min_minutes: number | null;
oil_check_wait_max_minutes: number | null;
oil_check_method: "dipstick" | "sight_glass" | null;
notes: string | null;
};
export function MotorcycleForm({ motorcycle }: { motorcycle?: MotorcycleValue }) {
const [oilCheckTemp, setOilCheckTemp] = useState<"" | "warm" | "cold">(
motorcycle?.oil_check_temp ?? "",
);
return (
<form action={motorcycle ? updateMotorcycleAction : createMotorcycleAction} className="panel form-grid">
{motorcycle && <input type="hidden" name="id" value={motorcycle.id} />}
@@ -57,6 +75,135 @@ export function MotorcycleForm({ motorcycle }: { motorcycle?: MotorcycleValue })
defaultValue={motorcycle?.current_km ?? 0}
/>
</label>
<label>
Reifendruck vorne (bar)
<input
name="tire_pressure_front_bar"
type="number"
min="0.5"
max="6"
step="0.1"
defaultValue={motorcycle?.tire_pressure_front_bar ?? ""}
/>
</label>
<label>
Reifendruck hinten (bar)
<input
name="tire_pressure_rear_bar"
type="number"
min="0.5"
max="6"
step="0.1"
defaultValue={motorcycle?.tire_pressure_rear_bar ?? ""}
/>
</label>
<label>
Kettenspannung von (mm)
<input
name="chain_tension_min_mm"
type="number"
min="1"
max="100"
step="1"
defaultValue={motorcycle?.chain_tension_min_mm ?? ""}
/>
</label>
<label>
Kettenspannung bis (mm)
<input
name="chain_tension_max_mm"
type="number"
min="1"
max="100"
step="1"
defaultValue={motorcycle?.chain_tension_max_mm ?? ""}
/>
</label>
<label>
Benötigtes Öl
<input
name="oil_type"
maxLength={80}
placeholder="z. B. 10W-40, JASO MA2"
defaultValue={motorcycle?.oil_type ?? ""}
/>
</label>
<label>
Ölmenge gesamt (Liter)
<input
name="oil_capacity_liters"
type="number"
min="0.1"
max="20"
step="0.1"
defaultValue={motorcycle?.oil_capacity_liters ?? ""}
/>
</label>
<label>
Öl prüfen: Motorzustand
<select
name="oil_check_temp"
value={oilCheckTemp}
onChange={(event) => setOilCheckTemp(event.target.value as "" | "warm" | "cold")}
>
<option value="">Bitte wählen</option>
<option value="cold">Kalt</option>
<option value="warm">Warm</option>
</select>
</label>
{oilCheckTemp === "warm" && (
<label>
Öl prüfen: Motor vorher laufen (Minuten)
<input
name="oil_check_run_minutes"
type="number"
min="0"
max="120"
step="1"
defaultValue={motorcycle?.oil_check_run_minutes ?? ""}
/>
</label>
)}
{oilCheckTemp === "warm" && (
<label>
Öl prüfen: Wartezeit min (Minuten)
<input
name="oil_check_wait_min_minutes"
type="number"
min="0"
max="120"
step="1"
defaultValue={motorcycle?.oil_check_wait_min_minutes ?? ""}
/>
</label>
)}
{oilCheckTemp === "warm" && (
<label>
Öl prüfen: Wartezeit max (Minuten)
<input
name="oil_check_wait_max_minutes"
type="number"
min="0"
max="120"
step="1"
defaultValue={motorcycle?.oil_check_wait_max_minutes ?? ""}
/>
</label>
)}
<label>
Öl prüfen: Methode
<select name="oil_check_method" defaultValue={motorcycle?.oil_check_method ?? ""}>
<option value="">Bitte wählen</option>
<option value="dipstick">Peilstab</option>
<option value="sight_glass">Schauglas</option>
</select>
</label>
{oilCheckTemp === "warm" && (
<p className="muted span-2">Bei warmem Motor kannst du Laufzeit und ein Min/Max-Wartefenster hinterlegen.</p>
)}
{oilCheckTemp === "cold" && (
<p className="muted span-2">Bei kaltem Motor wird ohne Wartezeit gemessen.</p>
)}
<label className="span-2">
Notizen
<textarea name="notes" rows={5} maxLength={5000} defaultValue={motorcycle?.notes ?? ""} />
+9 -7
View File
@@ -52,7 +52,7 @@ export async function getSessionUser(): Promise<SessionUser | null> {
const payload = result.payload as unknown as SessionPayload;
initDatabase();
const user = db
.prepare("SELECT id, name, email, role FROM users WHERE id = ?")
.prepare("SELECT id, name, email, role FROM users WHERE id = ? AND active = 1")
.get(Number(payload.id)) as
| { id: number; name: string; email: string; role: UserRole }
| undefined;
@@ -74,13 +74,15 @@ export async function requireAdminUser() {
return user;
}
export function authenticateUser(email: string, password: string): SessionUser | null {
export function authenticateUser(username: string, password: string): SessionUser | null {
initDatabase();
const user = db
.prepare("SELECT id, name, email, role, password_hash FROM users WHERE email = ?")
.get(email.trim().toLowerCase()) as
| (SessionUser & { password_hash: string })
| undefined;
const users = db
.prepare(
"SELECT id, name, email, role, password_hash FROM users WHERE name = ? COLLATE NOCASE AND active = 1 ORDER BY id ASC",
)
.all(username.trim()) as (SessionUser & { password_hash: string })[];
if (users.length !== 1) return null;
const user = users[0];
if (!user || !compareSync(password, user.password_hash)) return null;
return { id: user.id, name: user.name, email: user.email, role: user.role };
+149 -8
View File
@@ -1,4 +1,5 @@
import { DatabaseSync } from "node:sqlite";
import { randomUUID } from "node:crypto";
import { hashSync } from "bcryptjs";
import { getDatabasePath, getUploadDir } from "./env";
@@ -27,6 +28,8 @@ function ensureSchema() {
email TEXT NOT NULL COLLATE NOCASE UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('admin', 'member')),
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
deleted_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
) STRICT;
@@ -39,11 +42,32 @@ function ensureSchema() {
vin TEXT,
plate TEXT,
current_km INTEGER NOT NULL DEFAULT 0 CHECK (current_km >= 0),
tire_pressure_front_bar REAL CHECK (tire_pressure_front_bar IS NULL OR tire_pressure_front_bar > 0),
tire_pressure_rear_bar REAL CHECK (tire_pressure_rear_bar IS NULL OR tire_pressure_rear_bar > 0),
chain_tension_mm INTEGER CHECK (chain_tension_mm IS NULL OR chain_tension_mm >= 0),
chain_tension_min_mm INTEGER CHECK (chain_tension_min_mm IS NULL OR chain_tension_min_mm >= 0),
chain_tension_max_mm INTEGER CHECK (chain_tension_max_mm IS NULL OR chain_tension_max_mm >= 0),
oil_type TEXT,
oil_capacity_liters REAL CHECK (oil_capacity_liters IS NULL OR oil_capacity_liters > 0),
oil_check_temp TEXT CHECK (oil_check_temp IS NULL OR oil_check_temp IN ('warm', 'cold')),
oil_check_run_minutes INTEGER CHECK (oil_check_run_minutes IS NULL OR oil_check_run_minutes >= 0),
oil_check_wait_min_minutes INTEGER CHECK (oil_check_wait_min_minutes IS NULL OR oil_check_wait_min_minutes >= 0),
oil_check_wait_max_minutes INTEGER CHECK (oil_check_wait_max_minutes IS NULL OR oil_check_wait_max_minutes >= 0),
oil_check_wait_minutes INTEGER CHECK (oil_check_wait_minutes IS NULL OR oil_check_wait_minutes >= 0),
oil_check_method TEXT CHECK (oil_check_method IS NULL OR oil_check_method IN ('dipstick', 'sight_glass')),
notes TEXT,
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
) STRICT;
CREATE TABLE IF NOT EXISTS motorcycle_shares (
motorcycle_id INTEGER NOT NULL REFERENCES motorcycles(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
shared_by INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (motorcycle_id, user_id)
) STRICT;
CREATE TABLE IF NOT EXISTS maintenance_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
motorcycle_id INTEGER NOT NULL REFERENCES motorcycles(id) ON DELETE CASCADE,
@@ -87,11 +111,95 @@ function ensureSchema() {
ON maintenance_events(motorcycle_id, type, event_date DESC);
CREATE INDEX IF NOT EXISTS idx_trips_motorcycle
ON trips(motorcycle_id, trip_date DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_motorcycle_shares_user
ON motorcycle_shares(user_id, motorcycle_id);
`);
const userColumns = db.prepare("PRAGMA table_info(users)").all() as { name: string }[];
if (!userColumns.some((column) => column.name === "active")) {
db.exec("ALTER TABLE users ADD COLUMN active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1))");
}
if (!userColumns.some((column) => column.name === "deleted_at")) {
db.exec("ALTER TABLE users ADD COLUMN deleted_at TEXT");
}
const motorcycleColumns = db.prepare("PRAGMA table_info(motorcycles)").all() as { name: string }[];
if (!motorcycleColumns.some((column) => column.name === "tire_pressure_front_bar")) {
db.exec("ALTER TABLE motorcycles ADD COLUMN tire_pressure_front_bar REAL");
}
if (!motorcycleColumns.some((column) => column.name === "tire_pressure_rear_bar")) {
db.exec("ALTER TABLE motorcycles ADD COLUMN tire_pressure_rear_bar REAL");
}
if (!motorcycleColumns.some((column) => column.name === "chain_tension_mm")) {
db.exec("ALTER TABLE motorcycles ADD COLUMN chain_tension_mm INTEGER");
}
if (!motorcycleColumns.some((column) => column.name === "chain_tension_min_mm")) {
db.exec("ALTER TABLE motorcycles ADD COLUMN chain_tension_min_mm INTEGER");
}
if (!motorcycleColumns.some((column) => column.name === "chain_tension_max_mm")) {
db.exec("ALTER TABLE motorcycles ADD COLUMN chain_tension_max_mm INTEGER");
}
if (!motorcycleColumns.some((column) => column.name === "oil_type")) {
db.exec("ALTER TABLE motorcycles ADD COLUMN oil_type TEXT");
}
if (!motorcycleColumns.some((column) => column.name === "oil_capacity_liters")) {
db.exec("ALTER TABLE motorcycles ADD COLUMN oil_capacity_liters REAL");
}
if (!motorcycleColumns.some((column) => column.name === "oil_check_temp")) {
db.exec("ALTER TABLE motorcycles ADD COLUMN oil_check_temp TEXT");
}
if (!motorcycleColumns.some((column) => column.name === "oil_check_run_minutes")) {
db.exec("ALTER TABLE motorcycles ADD COLUMN oil_check_run_minutes INTEGER");
}
if (!motorcycleColumns.some((column) => column.name === "oil_check_wait_min_minutes")) {
db.exec("ALTER TABLE motorcycles ADD COLUMN oil_check_wait_min_minutes INTEGER");
}
if (!motorcycleColumns.some((column) => column.name === "oil_check_wait_max_minutes")) {
db.exec("ALTER TABLE motorcycles ADD COLUMN oil_check_wait_max_minutes INTEGER");
}
if (!motorcycleColumns.some((column) => column.name === "oil_check_wait_minutes")) {
db.exec("ALTER TABLE motorcycles ADD COLUMN oil_check_wait_minutes INTEGER");
}
if (!motorcycleColumns.some((column) => column.name === "oil_check_method")) {
db.exec("ALTER TABLE motorcycles ADD COLUMN oil_check_method TEXT");
}
const motorcycleColumnsAfter = db.prepare("PRAGMA table_info(motorcycles)").all() as { name: string }[];
if (
motorcycleColumnsAfter.some((column) => column.name === "chain_tension_mm") &&
motorcycleColumnsAfter.some((column) => column.name === "chain_tension_min_mm") &&
motorcycleColumnsAfter.some((column) => column.name === "chain_tension_max_mm")
) {
db.exec(`
UPDATE motorcycles
SET chain_tension_min_mm = chain_tension_mm,
chain_tension_max_mm = chain_tension_mm
WHERE chain_tension_mm IS NOT NULL
AND chain_tension_min_mm IS NULL
AND chain_tension_max_mm IS NULL
`);
}
if (
motorcycleColumnsAfter.some((column) => column.name === "oil_check_wait_minutes") &&
motorcycleColumnsAfter.some((column) => column.name === "oil_check_wait_min_minutes") &&
motorcycleColumnsAfter.some((column) => column.name === "oil_check_wait_max_minutes")
) {
db.exec(`
UPDATE motorcycles
SET oil_check_wait_min_minutes = oil_check_wait_minutes,
oil_check_wait_max_minutes = oil_check_wait_minutes
WHERE oil_check_wait_minutes IS NOT NULL
AND oil_check_wait_min_minutes IS NULL
AND oil_check_wait_max_minutes IS NULL
`);
}
db.exec("CREATE INDEX IF NOT EXISTS idx_users_active ON users(active, name COLLATE NOCASE)");
}
function ensureInitialAdmin() {
const row = db.prepare("SELECT COUNT(*) AS count FROM users").get() as
const row = db.prepare("SELECT COUNT(*) AS count FROM users WHERE active = 1").get() as
| { count: number }
| undefined;
@@ -99,13 +207,12 @@ function ensureInitialAdmin() {
return;
}
const email = process.env.ADMIN_EMAIL?.trim().toLowerCase();
const password = process.env.ADMIN_PASSWORD;
const name = process.env.ADMIN_NAME?.trim();
if (!email || !password || !name) {
if (!password || !name) {
throw new Error(
"Keine Benutzer vorhanden. ADMIN_EMAIL, ADMIN_PASSWORD und ADMIN_NAME müssen gesetzt sein.",
"Keine Benutzer vorhanden. ADMIN_PASSWORD und ADMIN_NAME müssen gesetzt sein.",
);
}
@@ -113,18 +220,52 @@ function ensureInitialAdmin() {
throw new Error("ADMIN_PASSWORD muss mindestens 10 Zeichen lang sein.");
}
const adminSlug =
name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "konto";
db.prepare(
"INSERT INTO users (name, email, password_hash, role) VALUES (?, ?, ?, 'admin')",
).run(name, email, hashSync(password, 12));
).run(name, `admin-${adminSlug}@local.invalid`, hashSync(password, 12));
}
function ensureReferencedUsersExist() {
const missingUserIds = db
.prepare(
`SELECT DISTINCT refs.user_id AS id
FROM (
SELECT created_by AS user_id FROM motorcycles
UNION
SELECT created_by AS user_id FROM maintenance_events
UNION
SELECT driver_user_id AS user_id FROM trips
) refs
LEFT JOIN users u ON u.id = refs.user_id
WHERE refs.user_id IS NOT NULL AND u.id IS NULL
ORDER BY refs.user_id`,
)
.all() as { id: number }[];
const insertMissingUser = db.prepare(
`INSERT INTO users (id, name, email, password_hash, role, active, deleted_at)
VALUES (?, ?, ?, ?, 'member', 0, CURRENT_TIMESTAMP)`,
);
for (const missing of missingUserIds) {
insertMissingUser.run(
missing.id,
`Gelöschter Benutzer #${missing.id}`,
`deleted-${missing.id}-${randomUUID()}@invalid.local`,
hashSync(randomUUID(), 12),
);
}
}
export function initDatabase() {
getUploadDir();
ensureSchema();
if (globalThis.__mopedDatabaseInitialized) {
return;
}
getUploadDir();
ensureSchema();
ensureReferencedUsersExist();
ensureInitialAdmin();
globalThis.__mopedDatabaseInitialized = true;
}
+2 -1
View File
@@ -12,7 +12,8 @@ export function getAuthSecret() {
}
export function getDataDir() {
const dataDir = process.env.DATA_DIR ?? path.join(process.cwd(), "data");
const dataDir =
process.env.DATA_DIR ?? path.join(/* turbopackIgnore: true */ process.cwd(), "data");
mkdirSync(dataDir, { recursive: true });
return dataDir;
}
+14
View File
@@ -25,6 +25,20 @@ export function integer(
return value;
}
export function decimal(
formData: FormData,
key: string,
options: { required?: boolean; min?: number; max?: number } = {},
) {
const raw = String(formData.get(key) ?? "").trim().replace(",", ".");
if (!raw && !options.required) return null;
const value = Number(raw);
if (!Number.isFinite(value)) throw new Error(`${key} muss eine Zahl sein.`);
if (options.min != null && value < options.min) throw new Error(`${key} ist zu klein.`);
if (options.max != null && value > options.max) throw new Error(`${key} ist zu groß.`);
return value;
}
export function dateValue(formData: FormData, key: string, required = false) {
const value = text(formData, key, { required, max: 10 });
if (!value) return null;