new init
@@ -1,3 +0,0 @@
|
|||||||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/bacchus
|
|
||||||
JWT_SECRET=<secret here>
|
|
||||||
RUST_LOG=bacchus=debug,axum=info,tower_http=info
|
|
2497
apps/backend/Cargo.lock
generated
@@ -1,17 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "bacchus"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
axum = { version = "0.8.4", features = ["macros","json"] }
|
|
||||||
tokio = { version = "1", features = ["full"] }
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
|
||||||
serde_json = "1"
|
|
||||||
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls","postgres","macros","uuid","chrono","migrate"] }
|
|
||||||
uuid = { version = "1", features = ["serde","v4"] }
|
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
|
||||||
tower-http = { version = "0.6.6", features = ["cors","trace"] }
|
|
||||||
tracing = "0.1"
|
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
|
||||||
anyhow = "1"
|
|
141
apps/backend/alembic.ini
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# path to migration scripts.
|
||||||
|
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||||
|
# format, relative to the token %(here)s which refers to the location of this
|
||||||
|
# ini file
|
||||||
|
script_location = %(here)s/alembic
|
||||||
|
|
||||||
|
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||||
|
# Uncomment the line below if you want the files to be prepended with date and time
|
||||||
|
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||||
|
# for all available tokens
|
||||||
|
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# sys.path path, will be prepended to sys.path if present.
|
||||||
|
# defaults to the current working directory. for multiple paths, the path separator
|
||||||
|
# is defined by "path_separator" below.
|
||||||
|
prepend_sys_path = .
|
||||||
|
|
||||||
|
|
||||||
|
# timezone to use when rendering the date within the migration file
|
||||||
|
# as well as the filename.
|
||||||
|
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
|
||||||
|
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
||||||
|
# string value is passed to ZoneInfo()
|
||||||
|
# leave blank for localtime
|
||||||
|
# timezone =
|
||||||
|
|
||||||
|
# max length of characters to apply to the "slug" field
|
||||||
|
# truncate_slug_length = 40
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
# set to 'true' to allow .pyc and .pyo files without
|
||||||
|
# a source .py file to be detected as revisions in the
|
||||||
|
# versions/ directory
|
||||||
|
# sourceless = false
|
||||||
|
|
||||||
|
# version location specification; This defaults
|
||||||
|
# to <script_location>/versions. When using multiple version
|
||||||
|
# directories, initial revisions must be specified with --version-path.
|
||||||
|
# The path separator used here should be the separator specified by "path_separator"
|
||||||
|
# below.
|
||||||
|
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||||
|
|
||||||
|
# path_separator; This indicates what character is used to split lists of file
|
||||||
|
# paths, including version_locations and prepend_sys_path within configparser
|
||||||
|
# files such as alembic.ini.
|
||||||
|
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||||
|
# to provide os-dependent path splitting.
|
||||||
|
#
|
||||||
|
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||||
|
# take place if path_separator is not present in alembic.ini. If this
|
||||||
|
# option is omitted entirely, fallback logic is as follows:
|
||||||
|
#
|
||||||
|
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||||
|
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||||
|
# behavior of splitting on spaces and/or commas.
|
||||||
|
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||||
|
# behavior of splitting on spaces, commas, or colons.
|
||||||
|
#
|
||||||
|
# Valid values for path_separator are:
|
||||||
|
#
|
||||||
|
# path_separator = :
|
||||||
|
# path_separator = ;
|
||||||
|
# path_separator = space
|
||||||
|
# path_separator = newline
|
||||||
|
#
|
||||||
|
# Use os.pathsep. Default configuration used for new projects.
|
||||||
|
path_separator = os
|
||||||
|
|
||||||
|
# set to 'true' to search source files recursively
|
||||||
|
# in each "version_locations" directory
|
||||||
|
# new in Alembic version 1.10
|
||||||
|
# recursive_version_locations = false
|
||||||
|
|
||||||
|
# the output encoding used when revision files
|
||||||
|
# are written from script.py.mako
|
||||||
|
# output_encoding = utf-8
|
||||||
|
|
||||||
|
# database URL. This is consumed by the user-maintained env.py script only.
|
||||||
|
# other means of configuring database URLs may be customized within the env.py
|
||||||
|
# file.
|
||||||
|
sqlalchemy.url = postgresql://postgres:bacchus@localhost:5432/bacchus
|
||||||
|
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
# post_write_hooks defines scripts or Python functions that are run
|
||||||
|
# on newly generated revision scripts. See the documentation for further
|
||||||
|
# detail and examples
|
||||||
|
|
||||||
|
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||||
|
# hooks = black
|
||||||
|
# black.type = console_scripts
|
||||||
|
# black.entrypoint = black
|
||||||
|
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = exec
|
||||||
|
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||||
|
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Logging configuration. This is also consumed by the user-maintained
|
||||||
|
# env.py script only.
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARNING
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARNING
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
31
apps/backend/app/api/admin_settings.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.auth import requires_role, get_current_user
|
||||||
|
from app.models.config import Config # Key/Value Tabelle: key (pk), value (text)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin/settings", tags=["admin-settings"], dependencies=[Depends(requires_role("admin","manager"))])
|
||||||
|
|
||||||
|
class PaypalCfg(BaseModel):
|
||||||
|
paypal_me: str | None = None
|
||||||
|
paypal_receiver: EmailStr | None = None
|
||||||
|
|
||||||
|
@router.get("/paypal", response_model=PaypalCfg)
|
||||||
|
def get_paypal(db: Session = Depends(get_db)):
|
||||||
|
def get(key):
|
||||||
|
obj = db.query(Config).get(key)
|
||||||
|
return obj.value if obj else None
|
||||||
|
return PaypalCfg(
|
||||||
|
paypal_me=get("paypal_me") or None,
|
||||||
|
paypal_receiver=get("paypal_receiver") or None
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.put("/paypal", response_model=PaypalCfg)
|
||||||
|
def put_paypal(cfg: PaypalCfg, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||||
|
for k,v in [("paypal_me", cfg.paypal_me), ("paypal_receiver", cfg.paypal_receiver)]:
|
||||||
|
row = db.query(Config).get(k)
|
||||||
|
if row: row.value = v or ""
|
||||||
|
else: db.add(Config(key=k, value=v or ""))
|
||||||
|
db.commit()
|
||||||
|
return cfg
|
108
apps/backend/app/api/admin_transactions.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy.exc import NoResultFound
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.auth import get_current_user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.transaction import Transaction # siehe Modell unten falls noch nicht vorhanden
|
||||||
|
from app.schemas.transaction import TransactionOut, TransactionCreate # siehe Schemas unten
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin/transactions", tags=["admin-transactions"])
|
||||||
|
|
||||||
|
def require_admin(current=Depends(get_current_user)):
|
||||||
|
if getattr(current, "role", None) != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="Admin only")
|
||||||
|
return current
|
||||||
|
|
||||||
|
@router.get("", response_model=List[TransactionOut])
|
||||||
|
def list_transactions(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_admin=Depends(require_admin),
|
||||||
|
limit: int = Query(200, ge=1, le=500),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
order: str = Query("desc", pattern="^(asc|desc)$"),
|
||||||
|
status: Optional[str] = Query(None, pattern="^(waiting|approved|rejected)$"),
|
||||||
|
):
|
||||||
|
q = select(Transaction).offset(offset).limit(limit)
|
||||||
|
if status:
|
||||||
|
q = q.where(Transaction.status == status)
|
||||||
|
if order == "desc":
|
||||||
|
q = q.order_by(Transaction.created_at.desc())
|
||||||
|
else:
|
||||||
|
q = q.order_by(Transaction.created_at.asc())
|
||||||
|
return db.execute(q).scalars().all()
|
||||||
|
|
||||||
|
@router.post("", response_model=TransactionOut, status_code=201)
|
||||||
|
def create_transaction(
|
||||||
|
body: TransactionCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin=Depends(require_admin),
|
||||||
|
):
|
||||||
|
tx = Transaction(
|
||||||
|
user_id=body.user_id,
|
||||||
|
amount_cents=body.amount_cents,
|
||||||
|
note=body.note or "",
|
||||||
|
status="waiting",
|
||||||
|
created_at=datetime.now(timezone.utc),
|
||||||
|
created_by_id=admin.id,
|
||||||
|
kind="manual",
|
||||||
|
)
|
||||||
|
db.add(tx)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(tx)
|
||||||
|
return tx
|
||||||
|
|
||||||
|
@router.post("/{tx_id}/approve", response_model=TransactionOut)
|
||||||
|
def approve_transaction(
|
||||||
|
tx_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin=Depends(require_admin),
|
||||||
|
):
|
||||||
|
tx = db.get(Transaction, tx_id)
|
||||||
|
if not tx:
|
||||||
|
raise HTTPException(404, "Transaktion nicht gefunden")
|
||||||
|
if tx.status != "waiting":
|
||||||
|
raise HTTPException(status_code=409, detail=f"Transaktion bereits {tx.status}")
|
||||||
|
|
||||||
|
# Nutzer sperren, um Doppelbuchungen zu vermeiden
|
||||||
|
user = db.execute(
|
||||||
|
select(User).where(User.id == tx.user_id).with_for_update()
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
new_balance = (user.balance_cents or 0) + tx.amount_cents
|
||||||
|
if new_balance < 0:
|
||||||
|
raise HTTPException(400, "Saldo würde negativ – Buchung abgelehnt")
|
||||||
|
|
||||||
|
user.balance_cents = new_balance
|
||||||
|
tx.status = "approved"
|
||||||
|
tx.processed_at = datetime.now(timezone.utc)
|
||||||
|
tx.processed_by_id = admin.id
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(tx)
|
||||||
|
return tx
|
||||||
|
|
||||||
|
@router.post("/{tx_id}/reject", response_model=TransactionOut)
|
||||||
|
def reject_transaction(
|
||||||
|
tx_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin=Depends(require_admin),
|
||||||
|
):
|
||||||
|
tx = db.get(Transaction, tx_id)
|
||||||
|
if not tx:
|
||||||
|
raise HTTPException(404, "Transaktion nicht gefunden")
|
||||||
|
if tx.status != "waiting":
|
||||||
|
raise HTTPException(status_code=409, detail=f"Transaktion bereits {tx.status}")
|
||||||
|
|
||||||
|
tx.status = "rejected"
|
||||||
|
tx.processed_at = datetime.now(timezone.utc)
|
||||||
|
tx.processed_by_id = admin.id
|
||||||
|
db.commit()
|
||||||
|
db.refresh(tx)
|
||||||
|
return tx
|
73
apps/backend/app/api/audit_logs.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
from app.core.auth import get_current_user, requires_role
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
from app.schemas.audit_log import AuditLogOut
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/audit-logs", tags=["audit-logs"])
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def _ts_col():
|
||||||
|
# vorhandene Zeitspalte wählen, sonst Fallback auf id
|
||||||
|
return (
|
||||||
|
getattr(AuditLog, "created_at", None)
|
||||||
|
or getattr(AuditLog, "timestamp", None)
|
||||||
|
or getattr(AuditLog, "created", None)
|
||||||
|
or AuditLog.id
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[AuditLogOut], dependencies=[Depends(requires_role("admin"))])
|
||||||
|
def list_audit_logs(
|
||||||
|
limit: int = Query(100, ge=1, le=1000),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
user_id: Optional[int] = Query(None, ge=1),
|
||||||
|
action: Optional[str] = Query(None),
|
||||||
|
q: Optional[str] = Query(None),
|
||||||
|
date_from: Optional[str] = Query(None),
|
||||||
|
date_to: Optional[str] = Query(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
qs = db.query(AuditLog)
|
||||||
|
|
||||||
|
if user_id is not None:
|
||||||
|
qs = qs.filter(AuditLog.user_id == user_id)
|
||||||
|
if action:
|
||||||
|
qs = qs.filter(AuditLog.action == action)
|
||||||
|
|
||||||
|
if q and hasattr(AuditLog, "info"):
|
||||||
|
like = f"%{q}%"
|
||||||
|
qs = qs.filter(AuditLog.info.ilike(like))
|
||||||
|
|
||||||
|
ts = _ts_col()
|
||||||
|
|
||||||
|
# ISO-Zeiten robust parsen
|
||||||
|
def _parse_iso(s: Optional[str]) -> Optional[datetime]:
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(s.replace("Z", "+00:00"))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
dtf = _parse_iso(date_from)
|
||||||
|
dtt = _parse_iso(date_to)
|
||||||
|
|
||||||
|
if dtf is not None and ts is not AuditLog.id:
|
||||||
|
qs = qs.filter(ts >= dtf)
|
||||||
|
if dtt is not None and ts is not AuditLog.id:
|
||||||
|
qs = qs.filter(ts <= dtt)
|
||||||
|
|
||||||
|
qs = qs.order_by(ts.desc(), AuditLog.id.desc()).limit(limit).offset(offset)
|
||||||
|
return qs.all()
|
292
apps/backend/app/api/auth.py
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Response, Body, Request, status
|
||||||
|
from sqlalchemy.orm import Session as DBSession
|
||||||
|
from sqlalchemy import func
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
import os, hmac, hashlib
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.auth import (
|
||||||
|
set_session_cookie,
|
||||||
|
issue_csrf_cookie,
|
||||||
|
clear_session_cookie,
|
||||||
|
clear_csrf_cookie,
|
||||||
|
get_current_user,
|
||||||
|
SESSION_COOKIE_NAME,
|
||||||
|
)
|
||||||
|
from app.services.sessions import create_session, delete_session
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
def verify_pin(plain_pin: str, hashed_pin: str) -> bool:
|
||||||
|
if not hashed_pin:
|
||||||
|
return False
|
||||||
|
return pwd_context.verify(plain_pin, hashed_pin)
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
if not hashed_password:
|
||||||
|
return False
|
||||||
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
# -------- Rolle robust normalisieren --------
|
||||||
|
def _normalize_role(role_raw) -> str:
|
||||||
|
# Enum? -> value
|
||||||
|
if hasattr(role_raw, "value"):
|
||||||
|
role_raw = role_raw.value
|
||||||
|
# Stringifizieren
|
||||||
|
role = str(role_raw or "user").strip()
|
||||||
|
# ggf. Präfixe/Namespaces entfernen (z. B. "userrole.admin")
|
||||||
|
if "." in role:
|
||||||
|
role = role.split(".")[-1]
|
||||||
|
return role.lower()
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# Throttling für Management-Login
|
||||||
|
# -----------------------------
|
||||||
|
_FAILED_LIMIT = 5
|
||||||
|
_LOCK_MINUTES = 2
|
||||||
|
_login_state = defaultdict(lambda: {"fails": 0, "lock_until": None})
|
||||||
|
|
||||||
|
def _client_ip(request: Request) -> str:
|
||||||
|
xff = request.headers.get("X-Forwarded-For")
|
||||||
|
if xff:
|
||||||
|
first = xff.split(",")[0].strip()
|
||||||
|
if first:
|
||||||
|
return first
|
||||||
|
return request.client.host if request.client else "0.0.0.0"
|
||||||
|
|
||||||
|
def _throttle_check(email_lower: str, ip: str):
|
||||||
|
st = _login_state[(email_lower, ip)]
|
||||||
|
now = datetime.utcnow()
|
||||||
|
if st["lock_until"] and now < st["lock_until"]:
|
||||||
|
seconds = int((st["lock_until"] - now).total_seconds())
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail=f"Login gesperrt. Bitte {seconds} Sekunden warten.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _throttle_fail(email_lower: str, ip: str):
|
||||||
|
st = _login_state[(email_lower, ip)]
|
||||||
|
st["fails"] += 1
|
||||||
|
if st["fails"] >= _FAILED_LIMIT:
|
||||||
|
st["lock_until"] = datetime.utcnow() + timedelta(minutes=_LOCK_MINUTES)
|
||||||
|
|
||||||
|
def _throttle_reset(email_lower: str, ip: str):
|
||||||
|
_login_state[(email_lower, ip)] = {"fails": 0, "lock_until": None}
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# PIN-Login: O(1) via HMAC-Lookup + IP-Rate-Limit
|
||||||
|
# -----------------------------
|
||||||
|
|
||||||
|
# Servergeheimer Pepper für HMAC über die **volle** PIN
|
||||||
|
PIN_PEPPER = os.getenv("PIN_PEPPER")
|
||||||
|
if not PIN_PEPPER or len(PIN_PEPPER) < 32:
|
||||||
|
where = os.environ.get("DOTENV_PATH") or ".env (nicht gefunden)"
|
||||||
|
raise RuntimeError(f"PIN_PEPPER env var fehlt/zu kurz – .env: {where}")
|
||||||
|
|
||||||
|
def _pin_hmac(pin: str) -> str:
|
||||||
|
"""hex(HMAC_SHA256(PEPPER, pin))"""
|
||||||
|
return hmac.new(PIN_PEPPER.encode("utf-8"), pin.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
# sehr einfaches IP-Rate-Limit (pro Prozess)
|
||||||
|
_pin_attempts = defaultdict(list) # ip -> [timestamps]
|
||||||
|
|
||||||
|
def _pin_rate_limit_ok(ip: str, limit: int = 10, window_sec: int = 60) -> bool:
|
||||||
|
now = datetime.utcnow().timestamp()
|
||||||
|
arr = _pin_attempts[ip]
|
||||||
|
# altes Fenster abschneiden
|
||||||
|
arr[:] = [t for t in arr if now - t < window_sec]
|
||||||
|
if len(arr) >= limit:
|
||||||
|
return False
|
||||||
|
arr.append(now)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# Management-Login (E-Mail/Passwort)
|
||||||
|
# -----------------------------
|
||||||
|
@router.post("/management-login")
|
||||||
|
def management_login(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
email: str = Body(..., embed=True),
|
||||||
|
password: str = Body(..., embed=True),
|
||||||
|
db: DBSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
email_lower = (email or "").strip().lower()
|
||||||
|
ip = _client_ip(request)
|
||||||
|
|
||||||
|
_throttle_check(email_lower, ip)
|
||||||
|
|
||||||
|
user = db.query(User).filter(func.lower(User.email) == email_lower).one_or_none()
|
||||||
|
if not user or not getattr(user, "is_active", True):
|
||||||
|
_throttle_fail(email_lower, ip)
|
||||||
|
raise HTTPException(status_code=401, detail="Ungültige Zugangsdaten")
|
||||||
|
|
||||||
|
hashed_pw = getattr(user, "hashed_password", None) or getattr(user, "password_hash", None)
|
||||||
|
if not hashed_pw:
|
||||||
|
_throttle_fail(email_lower, ip)
|
||||||
|
raise HTTPException(status_code=401, detail="Ungültige Zugangsdaten")
|
||||||
|
|
||||||
|
if not verify_password(password, hashed_pw):
|
||||||
|
_throttle_fail(email_lower, ip)
|
||||||
|
raise HTTPException(status_code=401, detail="Ungültige Zugangsdaten")
|
||||||
|
|
||||||
|
role = _normalize_role(getattr(user, "role", "user"))
|
||||||
|
|
||||||
|
_throttle_reset(email_lower, ip)
|
||||||
|
|
||||||
|
token = create_session(db, user.id)
|
||||||
|
set_session_cookie(response, token)
|
||||||
|
issue_csrf_cookie(response)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Login erfolgreich",
|
||||||
|
"user": {"id": user.id, "name": user.name, "role": role},
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# PIN-Login (O(1) Kandidaten, dann 1× Hash-Verify)
|
||||||
|
# -----------------------------
|
||||||
|
@router.post("/pin-login")
|
||||||
|
def pin_login(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
pin: str = Body(..., embed=True),
|
||||||
|
db: DBSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
# IP-Rate-Limit (z. B. 10 Versuche/60s)
|
||||||
|
ip = _client_ip(request)
|
||||||
|
if not _pin_rate_limit_ok(ip):
|
||||||
|
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Zu viele Versuche. Bitte kurz warten.")
|
||||||
|
|
||||||
|
if not pin or not pin.isdigit() or len(pin) != 6:
|
||||||
|
# gleiche Antwortzeit für Fehler
|
||||||
|
raise HTTPException(status_code=401, detail="Ungültiger PIN")
|
||||||
|
|
||||||
|
token = _pin_hmac(pin)
|
||||||
|
|
||||||
|
# Schnellpfad: direkt per HMAC-Token Kandidaten suchen (O(1) via Index)
|
||||||
|
candidates = db.query(User).filter(
|
||||||
|
User.is_active == True,
|
||||||
|
getattr(User, "pin_lookup") == token # erwartet Spalte users.pin_lookup (VARCHAR(64))
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Normalfall: genau 1 Kandidat; bei Kollisionen (gleiche PIN) sind es wenige
|
||||||
|
matched = None
|
||||||
|
for u in candidates:
|
||||||
|
# sichere Verifikation mit dem langsamen Hash
|
||||||
|
hashed_pin = getattr(u, "hashed_pin", None) or getattr(u, "pin_hash", None)
|
||||||
|
if hashed_pin and verify_pin(pin, hashed_pin):
|
||||||
|
matched = u
|
||||||
|
break
|
||||||
|
|
||||||
|
if not matched:
|
||||||
|
# Altbestand (pin_lookup leer) – langsamer Fallback: aktive Nutzer iterieren
|
||||||
|
# Hinweis: Bei vielen Nutzern besser deaktivieren und stattdessen Nutzer zur PIN-Neusetzung zwingen.
|
||||||
|
slow = db.query(User).filter(User.is_active == True).with_entities(User.id, User.hashed_pin).all()
|
||||||
|
for uid, h in slow:
|
||||||
|
if h and verify_pin(pin, h):
|
||||||
|
matched = db.query(User).get(uid) # type: ignore
|
||||||
|
if matched:
|
||||||
|
# Nachmigration: pin_lookup setzen, damit künftige Logins schnell sind
|
||||||
|
setattr(matched, "pin_lookup", token)
|
||||||
|
db.add(matched)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(matched)
|
||||||
|
break
|
||||||
|
|
||||||
|
if not matched:
|
||||||
|
raise HTTPException(status_code=401, detail="Ungültiger PIN")
|
||||||
|
|
||||||
|
# Erfolg → Session-Cookie + CSRF
|
||||||
|
token_val = create_session(db, matched.id)
|
||||||
|
set_session_cookie(response, token_val)
|
||||||
|
issue_csrf_cookie(response)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Login erfolgreich",
|
||||||
|
"user": {
|
||||||
|
"id": matched.id,
|
||||||
|
"name": matched.name,
|
||||||
|
"role": _normalize_role(getattr(matched, "role", "user")),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# Logout
|
||||||
|
# -----------------------------
|
||||||
|
@router.post("/logout")
|
||||||
|
def logout(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
db: DBSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||||
|
if token:
|
||||||
|
delete_session(db, token)
|
||||||
|
clear_session_cookie(response)
|
||||||
|
clear_csrf_cookie(response)
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# CSRF-Token beziehen
|
||||||
|
# -----------------------------
|
||||||
|
@router.get("/csrf")
|
||||||
|
def get_csrf(response: Response):
|
||||||
|
issue_csrf_cookie(response)
|
||||||
|
return {"csrf": "ok"}
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# Aktueller Nutzer
|
||||||
|
# -----------------------------
|
||||||
|
@router.get("/me")
|
||||||
|
def me(current_user: User = Depends(get_current_user)):
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# Capabilities (UI-Gating)
|
||||||
|
# -----------------------------
|
||||||
|
@router.get("/capabilities")
|
||||||
|
def get_capabilities(current_user: User = Depends(get_current_user)):
|
||||||
|
role = _normalize_role(getattr(current_user, "role", "user"))
|
||||||
|
|
||||||
|
caps_by_role = {
|
||||||
|
"user": [
|
||||||
|
"dashboard",
|
||||||
|
"profile",
|
||||||
|
"my-bookings",
|
||||||
|
"my-transactions",
|
||||||
|
"system-config",
|
||||||
|
"stats-advanced",
|
||||||
|
],
|
||||||
|
"manager": [
|
||||||
|
"dashboard",
|
||||||
|
"profile",
|
||||||
|
"my-bookings",
|
||||||
|
"my-transactions",
|
||||||
|
"products",
|
||||||
|
"deliveries",
|
||||||
|
"system-config",
|
||||||
|
"stats-advanced",
|
||||||
|
],
|
||||||
|
"admin": [
|
||||||
|
"dashboard",
|
||||||
|
"profile",
|
||||||
|
"my-bookings",
|
||||||
|
"my-transactions",
|
||||||
|
"users",
|
||||||
|
"products",
|
||||||
|
"deliveries",
|
||||||
|
"stats-advanced",
|
||||||
|
"audit-logs",
|
||||||
|
"system-config",
|
||||||
|
"transactions",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"role": role, "capabilities": caps_by_role.get(role, caps_by_role["user"])}
|
171
apps/backend/app/api/bookings.py
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.auth import get_current_user, requires_role
|
||||||
|
from app.models.booking import Booking
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.product import Product
|
||||||
|
from app.schemas.booking import BookingOut, BookingCreate
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/bookings", tags=["bookings"])
|
||||||
|
|
||||||
|
# Konstante aus deinen Policies: Nutzer dürfen nicht < 0 € fallen (Stand 2025-07-02).
|
||||||
|
ALLOWED_OVERDRAFT_CENTS = 0 # falls -500 erlaubt sein soll: setze auf -500
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=BookingOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_booking(
|
||||||
|
booking: BookingCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
if current_user.role not in ("manager", "admin") and booking.user_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
|
||||||
|
if booking.amount is None or booking.amount <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid amount")
|
||||||
|
|
||||||
|
# Produkt mit Row-Lock
|
||||||
|
product = (
|
||||||
|
db.query(Product)
|
||||||
|
.filter(Product.id == booking.product_id)
|
||||||
|
.with_for_update()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not product:
|
||||||
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
|
if hasattr(product, "is_active") and not bool(product.is_active):
|
||||||
|
raise HTTPException(status_code=400, detail="Product is not active")
|
||||||
|
|
||||||
|
qty = int(booking.amount)
|
||||||
|
# Bestand prüfen/abziehen, falls Feld vorhanden
|
||||||
|
if hasattr(product, "stock"):
|
||||||
|
current_stock = int(product.stock or 0)
|
||||||
|
if current_stock < qty:
|
||||||
|
raise HTTPException(status_code=409, detail="Not enough stock")
|
||||||
|
product.stock = current_stock - qty
|
||||||
|
|
||||||
|
server_total = int(product.price_cents) * qty
|
||||||
|
|
||||||
|
try:
|
||||||
|
# User mit Row-Lock
|
||||||
|
user = (
|
||||||
|
db.query(User)
|
||||||
|
.filter(User.id == booking.user_id)
|
||||||
|
.with_for_update()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
new_balance = int(user.balance_cents) - server_total
|
||||||
|
if new_balance < -ALLOWED_OVERDRAFT_CENTS:
|
||||||
|
raise HTTPException(status_code=400, detail="Insufficient balance")
|
||||||
|
|
||||||
|
user.balance_cents = new_balance
|
||||||
|
|
||||||
|
db_booking = Booking(
|
||||||
|
user_id=booking.user_id,
|
||||||
|
product_id=booking.product_id,
|
||||||
|
amount=qty,
|
||||||
|
total_cents=server_total, # Client-Wert wird ignoriert
|
||||||
|
)
|
||||||
|
db.add(db_booking)
|
||||||
|
db.flush()
|
||||||
|
db.refresh(db_booking)
|
||||||
|
db.commit()
|
||||||
|
return db_booking
|
||||||
|
except HTTPException:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _role_name(r) -> str:
|
||||||
|
# robust gegen Enum/String/None
|
||||||
|
if r is None:
|
||||||
|
return ""
|
||||||
|
v = getattr(r, "value", r)
|
||||||
|
return str(v).strip().lower()
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[BookingOut])
|
||||||
|
def list_bookings(
|
||||||
|
limit: int = Query(100, ge=1, le=1000),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
user_id: Optional[int] = Query(None, ge=1),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
q = db.query(Booking)
|
||||||
|
|
||||||
|
role = _role_name(getattr(current_user, "role", None))
|
||||||
|
is_manager = role in ("manager", "admin")
|
||||||
|
|
||||||
|
if is_manager:
|
||||||
|
# Admin/Manager: optionaler Filter auf user_id
|
||||||
|
if user_id is not None:
|
||||||
|
q = q.filter(Booking.user_id == user_id)
|
||||||
|
else:
|
||||||
|
# Normale Nutzer: strikt nur eigene Buchungen
|
||||||
|
q = q.filter(Booking.user_id == current_user.id)
|
||||||
|
|
||||||
|
# stabile, absteigende Reihenfolge – Zeitfeld optional
|
||||||
|
order_cols = []
|
||||||
|
if hasattr(Booking, "created_at"):
|
||||||
|
order_cols.append(Booking.created_at.desc())
|
||||||
|
elif hasattr(Booking, "timestamp"):
|
||||||
|
order_cols.append(Booking.timestamp.desc())
|
||||||
|
elif hasattr(Booking, "created"):
|
||||||
|
order_cols.append(Booking.created.desc())
|
||||||
|
order_cols.append(Booking.id.desc())
|
||||||
|
|
||||||
|
return q.order_by(*order_cols).limit(limit).offset(offset).all()
|
||||||
|
|
||||||
|
@router.get("/me", response_model=List[BookingOut])
|
||||||
|
def list_my_bookings(
|
||||||
|
limit: int = Query(100, ge=1, le=1000),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
q = (
|
||||||
|
db.query(Booking)
|
||||||
|
.filter(Booking.user_id == current_user.id)
|
||||||
|
.order_by(Booking.id.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
return q.all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{booking_id}", response_model=BookingOut)
|
||||||
|
def get_booking(
|
||||||
|
booking_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
b = db.query(Booking).filter(Booking.id == booking_id).first()
|
||||||
|
if not b:
|
||||||
|
raise HTTPException(status_code=404, detail="Booking not found")
|
||||||
|
if b.user_id != current_user.id and current_user.role not in ("manager", "admin"):
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{booking_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_booking(
|
||||||
|
booking_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
requires_role("manager", "admin")(current_user)
|
||||||
|
b = db.query(Booking).filter(Booking.id == booking_id).first()
|
||||||
|
if not b:
|
||||||
|
raise HTTPException(status_code=404, detail="Booking not found")
|
||||||
|
db.delete(b)
|
||||||
|
db.commit()
|
||||||
|
return None
|
85
apps/backend/app/api/categories.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# app/routes/categories.py
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
from app.core.auth import get_current_user, requires_role
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.product import Product
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/categories", tags=["categories"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[str])
|
||||||
|
def list_categories(db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Alle Kategorien (distinct aus Product.category).
|
||||||
|
"""
|
||||||
|
cats = db.query(Product.category).distinct().all()
|
||||||
|
return sorted([c[0] for c in cats if c[0]])
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/rename",
|
||||||
|
dependencies=[Depends(requires_role("manager", "admin"))],
|
||||||
|
)
|
||||||
|
def rename_category(
|
||||||
|
old_name: str,
|
||||||
|
new_name: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Kategorie umbenennen: alle Produkte mit old_name -> new_name.
|
||||||
|
"""
|
||||||
|
if not new_name or new_name.lower() == "alle":
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid category name")
|
||||||
|
|
||||||
|
updated = (
|
||||||
|
db.query(Product)
|
||||||
|
.filter(Product.category == old_name)
|
||||||
|
.update({"category": new_name})
|
||||||
|
)
|
||||||
|
if updated == 0:
|
||||||
|
raise HTTPException(status_code=404, detail="Category not found")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"updated": updated, "new_name": new_name}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/",
|
||||||
|
dependencies=[Depends(requires_role("manager", "admin"))],
|
||||||
|
)
|
||||||
|
def delete_category(
|
||||||
|
name: str,
|
||||||
|
reassign_to: str | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Kategorie löschen.
|
||||||
|
- Mit reassign_to: alle Produkte auf neue Kategorie umhängen.
|
||||||
|
- Ohne: Kategorie auf NULL setzen.
|
||||||
|
"""
|
||||||
|
q = db.query(Product).filter(Product.category == name)
|
||||||
|
|
||||||
|
if not q.first():
|
||||||
|
raise HTTPException(status_code=404, detail="Category not found")
|
||||||
|
|
||||||
|
if reassign_to:
|
||||||
|
updated = q.update({"category": reassign_to})
|
||||||
|
else:
|
||||||
|
updated = q.update({"category": None})
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"updated": updated, "reassigned_to": reassign_to}
|
284
apps/backend/app/api/deliveries.py
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List, Optional
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from io import BytesIO
|
||||||
|
from datetime import date
|
||||||
|
import re
|
||||||
|
import difflib
|
||||||
|
import datetime as dt
|
||||||
|
try:
|
||||||
|
import pdfplumber # type: ignore
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
pdfplumber = None
|
||||||
|
|
||||||
|
from app.schemas.delivery import DeliveryCreate, DeliveryOut
|
||||||
|
from app.models.delivery import Delivery
|
||||||
|
from app.models.product import Product
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
from app.core.auth import get_current_user, requires_role
|
||||||
|
from app.models.user import User
|
||||||
|
router = APIRouter(prefix="/deliveries", tags=["deliveries"])
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@router.post("/", response_model=DeliveryOut, dependencies=[Depends(requires_role("manager", "admin"))])
|
||||||
|
def create_delivery(delivery: DeliveryCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
# Session.get statt .query().get (SQLAlchemy 2.x)
|
||||||
|
product = db.get(Product, delivery.product_id)
|
||||||
|
if not product:
|
||||||
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
|
if delivery.amount < 1:
|
||||||
|
raise HTTPException(status_code=400, detail="Amount must be at least 1")
|
||||||
|
|
||||||
|
# Pydantic v2: model_dump()
|
||||||
|
db_delivery = Delivery(**delivery.model_dump())
|
||||||
|
db.add(db_delivery)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_delivery)
|
||||||
|
return db_delivery
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[DeliveryOut], dependencies=[Depends(requires_role("manager", "admin"))])
|
||||||
|
def list_deliveries(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
return db.query(Delivery).all()
|
||||||
|
|
||||||
|
@router.get("/{delivery_id}", response_model=DeliveryOut, dependencies=[Depends(requires_role("manager", "admin"))])
|
||||||
|
def get_delivery(delivery_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
delivery = db.get(Delivery, delivery_id)
|
||||||
|
if not delivery:
|
||||||
|
raise HTTPException(status_code=404, detail="Delivery not found")
|
||||||
|
return delivery
|
||||||
|
|
||||||
|
@router.delete("/{delivery_id}", status_code=204, dependencies=[Depends(requires_role("manager", "admin"))])
|
||||||
|
def delete_delivery(delivery_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
delivery = db.get(Delivery, delivery_id)
|
||||||
|
if not delivery:
|
||||||
|
raise HTTPException(status_code=404, detail="Delivery not found")
|
||||||
|
db.delete(delivery)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# ---- Draft-Schemata für den PDF-Import (Antwort an das Frontend) -----------------------
|
||||||
|
class DeliveryItemDraft(BaseModel):
|
||||||
|
product_id: Optional[int] = None
|
||||||
|
product_hint: Optional[str] = None # originaler Text aus der PDF
|
||||||
|
quantity_units: int # Anzahl (Einheiten/KA)
|
||||||
|
unit_cost_cents: int # E-Preis je Einheit in Cent
|
||||||
|
|
||||||
|
class DeliveryDraft(BaseModel):
|
||||||
|
supplier: Optional[str] = None
|
||||||
|
date: Optional[str] = None # ISO yyyy-mm-dd
|
||||||
|
invoice_no: Optional[str] = None
|
||||||
|
note: Optional[str] = None
|
||||||
|
deposit_return_cents: int = 0 # Netto-Pfand (Cent, positiv)
|
||||||
|
items: List[DeliveryItemDraft] = []
|
||||||
|
|
||||||
|
# ---- Hilfsfunktionen -------------------------------------------------------------------
|
||||||
|
def _parse_decimal_de(s: str) -> float:
|
||||||
|
"""'1.234,56' -> 1234.56"""
|
||||||
|
t = s.strip().replace(".", "").replace("’", "").replace(",", ".")
|
||||||
|
try:
|
||||||
|
return float(t)
|
||||||
|
except ValueError:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def _to_cents(v: float) -> int:
|
||||||
|
return int(round(v * 100))
|
||||||
|
|
||||||
|
def _date_de_to_iso(s: str) -> Optional[str]:
|
||||||
|
try:
|
||||||
|
d = dt.datetime.strptime(s.strip(), "%d.%m.%Y").date()
|
||||||
|
return d.isoformat()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
_pack_pat = re.compile(r"(\d+)\s*/\s*(\d+[.,]?\d*)") # z.B. 24/0,33 oder 12/1,0
|
||||||
|
|
||||||
|
def _extract_pack_size(text: str) -> Optional[int]:
|
||||||
|
m = _pack_pat.search(text)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(m.group(1))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _norm(s: str) -> str:
|
||||||
|
return re.sub(r"[^0-9a-z]+", " ", s.lower()).strip()
|
||||||
|
|
||||||
|
def _best_product_match(desc: str, products: List[Product]) -> Optional[int]:
|
||||||
|
"""Einfache Fuzzy-Suche: Textähnlichkeit + Bonus bei passender Packgröße."""
|
||||||
|
if not products:
|
||||||
|
return None
|
||||||
|
cand_name = _norm(desc)
|
||||||
|
want_pack = _extract_pack_size(desc)
|
||||||
|
best_id, best_score = None, 0.0
|
||||||
|
for p in products:
|
||||||
|
name = getattr(p, "name", "") or ""
|
||||||
|
score = difflib.SequenceMatcher(a=cand_name, b=_norm(name)).ratio()
|
||||||
|
pack = getattr(p, "pack_size", None)
|
||||||
|
if want_pack and pack and int(pack) == int(want_pack):
|
||||||
|
score += 0.08 # leichter Bonus
|
||||||
|
if score > best_score:
|
||||||
|
best_score, best_id = score, int(getattr(p, "id"))
|
||||||
|
return best_id if best_score >= 0.75 else None
|
||||||
|
|
||||||
|
# ---- Kern: PDF parsen (ein Supplier, textbasiert) --------------------------------------
|
||||||
|
def _parse_invoice_pdf(data: bytes, all_products: List[Product]) -> DeliveryDraft:
|
||||||
|
if pdfplumber is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=501,
|
||||||
|
detail="pdfplumber ist nicht installiert. Bitte `pip install pdfplumber` ausführen."
|
||||||
|
)
|
||||||
|
|
||||||
|
text_pages: List[str] = []
|
||||||
|
with pdfplumber.open(BytesIO(data)) as pdf:
|
||||||
|
for page in pdf.pages:
|
||||||
|
# Text mit Zeilenumbrüchen; für dieses Layout reicht Text-Parsing
|
||||||
|
text_pages.append(page.extract_text() or "")
|
||||||
|
|
||||||
|
full = "\n".join(text_pages)
|
||||||
|
|
||||||
|
# Header-Felder
|
||||||
|
inv = None
|
||||||
|
m = re.search(r"Rechnungs[-\s]?Nr\.?\s*:\s*([A-Za-z0-9\-\/]+)", full, re.I)
|
||||||
|
if m:
|
||||||
|
inv = m.group(1).strip()
|
||||||
|
|
||||||
|
date_iso = None
|
||||||
|
m = re.search(r"Datum\s*:\s*(\d{2}\.\d{2}\.\d{4})", full, re.I)
|
||||||
|
if m:
|
||||||
|
date_iso = _date_de_to_iso(m.group(1))
|
||||||
|
|
||||||
|
deposit_cents = 0
|
||||||
|
|
||||||
|
# 1) Bevorzugt die Steuerzeile netto Summe Pfand
|
||||||
|
m = re.search(r"(?mi)^\s*summe\s+pfand\b.*?([0-9\.,]+)\s*$", full)
|
||||||
|
if not m:
|
||||||
|
# 2) Fallback
|
||||||
|
m = re.search(r"(?mi)^\s*pfand\b.*?eur\s*([0-9\.,]+)\s*$", full)
|
||||||
|
|
||||||
|
if m:
|
||||||
|
deposit_cents = _to_cents(_parse_decimal_de(m.group(1)))
|
||||||
|
|
||||||
|
# Positionsblock: Zeilen zwischen Kopf "Art-Nr" und "Zwischensumme Warenwert"
|
||||||
|
items: List[DeliveryItemDraft] = []
|
||||||
|
block = []
|
||||||
|
in_block = False
|
||||||
|
for line in full.splitlines():
|
||||||
|
if not in_block and re.search(r"\bArt[-\s]?Nr\b", line):
|
||||||
|
in_block = True
|
||||||
|
continue
|
||||||
|
if in_block and re.search(r"Zwischensumme\s+Warenwert", line):
|
||||||
|
break
|
||||||
|
if in_block:
|
||||||
|
if line.strip():
|
||||||
|
block.append(line)
|
||||||
|
|
||||||
|
# Zeilen parsen: "<artnr> <bezeichnung…> <anzahl> <ME> <E-Preis> <G-Preis>"
|
||||||
|
row_re = re.compile(
|
||||||
|
r"^\s*\d+\s+(?P<desc>.+?)\s+(?P<qty>\d+)\s+(?P<me>[A-Za-z]+)\s+(?P<eprice>\d+,\d{2})\s+(?P<gprice>\d+,\d{2})\s*$"
|
||||||
|
)
|
||||||
|
for ln in block:
|
||||||
|
m = row_re.match(ln)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
desc = m.group("desc").strip()
|
||||||
|
qty = int(m.group("qty"))
|
||||||
|
me = m.group("me").upper()
|
||||||
|
eprice = _to_cents(_parse_decimal_de(m.group("eprice")))
|
||||||
|
# Nur ME = KA (Kästen/Einheit) übernehmen – andere ignorieren
|
||||||
|
if me not in {"KA", "KASTEN", "EINHEIT"}:
|
||||||
|
# zur Not trotzdem übernehmen
|
||||||
|
pass
|
||||||
|
prod_id = _best_product_match(desc, all_products)
|
||||||
|
items.append(
|
||||||
|
DeliveryItemDraft(
|
||||||
|
product_id=prod_id,
|
||||||
|
product_hint=(None if prod_id else desc),
|
||||||
|
quantity_units=qty,
|
||||||
|
unit_cost_cents=eprice,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return DeliveryDraft(
|
||||||
|
supplier=None, # optional – kann bei Bedarf aus Kopf extrahiert werden
|
||||||
|
date=date_iso,
|
||||||
|
invoice_no=inv,
|
||||||
|
note=None,
|
||||||
|
deposit_return_cents=abs(deposit_cents),
|
||||||
|
items=items or [],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- Endpoint: PDF hochladen & Draft liefern -------------------------------------------
|
||||||
|
@router.post("/invoice/import", response_model=DeliveryDraft, dependencies=[Depends(requires_role("manager", "admin"))])
|
||||||
|
def import_invoice_pdf(
|
||||||
|
file: UploadFile = File(...), # ← fix
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
if not file.filename.lower().endswith(".pdf"):
|
||||||
|
raise HTTPException(status_code=400, detail="Bitte eine PDF-Datei hochladen.")
|
||||||
|
|
||||||
|
data = file.file.read()
|
||||||
|
if not data:
|
||||||
|
raise HTTPException(status_code=400, detail="Leere Datei.")
|
||||||
|
|
||||||
|
# Produkte für Matching laden
|
||||||
|
products = db.query(Product).all()
|
||||||
|
|
||||||
|
try:
|
||||||
|
draft = _parse_invoice_pdf(data, products)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e: # robustes Fehlerhandling
|
||||||
|
raise HTTPException(status_code=422, detail=f"PDF konnte nicht geparst werden: {e}")
|
||||||
|
|
||||||
|
# Optional: minimale Plausibilitätsprüfung
|
||||||
|
if not draft.items:
|
||||||
|
# Kein harter Fehler – Frontend kann manuell ergänzen
|
||||||
|
draft.note = (draft.note or "")
|
||||||
|
return draft
|
||||||
|
|
||||||
|
# ---- Bulk-Schema ----
|
||||||
|
class DeliveryItemIn(BaseModel):
|
||||||
|
product_id: int
|
||||||
|
quantity_units: int
|
||||||
|
unit_cost_cents: int
|
||||||
|
units: int | None = None # optional: Stückzahl vom Client
|
||||||
|
|
||||||
|
class DeliveryBulkIn(BaseModel):
|
||||||
|
supplier: str
|
||||||
|
date: date
|
||||||
|
invoice_no: str | None = None
|
||||||
|
note: str | None = None
|
||||||
|
deposit_return_cents: int = 0
|
||||||
|
items: list[DeliveryItemIn]
|
||||||
|
|
||||||
|
@router.post("/bulk")
|
||||||
|
def create_delivery_bulk(body: DeliveryBulkIn, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
requires_role("manager", "admin")(user)
|
||||||
|
created_ids: list[int] = []
|
||||||
|
deposit = int(body.deposit_return_cents or 0)
|
||||||
|
for idx, it in enumerate(body.items):
|
||||||
|
prod = db.get(Product, it.product_id) or HTTPException(status_code=400, detail=f"Unknown product_id {it.product_id}")
|
||||||
|
pack = int(getattr(prod, "pack_size") or 1)
|
||||||
|
amount_pieces = int(it.units) if it.units is not None else int(it.quantity_units) * pack
|
||||||
|
row = Delivery(
|
||||||
|
product_id=it.product_id,
|
||||||
|
amount=amount_pieces, # Stück gesamt
|
||||||
|
price_cents=int(it.unit_cost_cents), # Preis pro Stück
|
||||||
|
delivered_at=body.date,
|
||||||
|
supplier=body.supplier,
|
||||||
|
invoice_number=body.invoice_no,
|
||||||
|
note=body.note,
|
||||||
|
deposit_return_cents=(deposit if idx == 0 else 0), # <- hier
|
||||||
|
)
|
||||||
|
db.add(row); db.flush(); created_ids.append(row.id)
|
||||||
|
db.commit()
|
||||||
|
return {"created": created_ids}
|
||||||
|
|
126
apps/backend/app/api/ledger.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
from typing import List, Optional, Literal, Set
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from fastapi import Body
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.auth import get_current_user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.topup import Topup
|
||||||
|
from app.models.booking import Booking
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/ledger", tags=["ledger"])
|
||||||
|
|
||||||
|
class LedgerItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
type: Literal["topup", "booking"]
|
||||||
|
amount_cents: int # topup > 0, booking < 0
|
||||||
|
created_at: str # ISO-String
|
||||||
|
status: Optional[str] = None # nur für topup: pending | confirmed | rejected
|
||||||
|
note: Optional[str] = None # nur für topup/admin: Notiz / 5-stelliger Code
|
||||||
|
product_id: Optional[int] = None
|
||||||
|
product_name: Optional[str] = None
|
||||||
|
|
||||||
|
@router.get("/me", response_model=List[LedgerItem])
|
||||||
|
def list_ledger_me(
|
||||||
|
limit: int = Query(100, ge=1, le=1000),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
types: str = Query("topup,booking", description="CSV: topup,booking"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Vereinheitlichte Übersicht über Aufladungen (Top-ups) und Getränkebuchungen.
|
||||||
|
Global nach Datum sortiert, danach paginiert.
|
||||||
|
"""
|
||||||
|
want: Set[str] = {t.strip().lower() for t in types.split(",") if t.strip()}
|
||||||
|
|
||||||
|
rows: list[LedgerItem] = []
|
||||||
|
|
||||||
|
if "topup" in want:
|
||||||
|
topups = (
|
||||||
|
db.query(Topup)
|
||||||
|
.filter(Topup.user_id == current_user.id)
|
||||||
|
.order_by(Topup.id.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for t in topups:
|
||||||
|
rows.append(
|
||||||
|
LedgerItem(
|
||||||
|
id=f"topup-{t.id}",
|
||||||
|
type="topup",
|
||||||
|
amount_cents=int(t.amount_cents or 0),
|
||||||
|
created_at=(t.created_at.isoformat() if getattr(t, "created_at", None) else ""),
|
||||||
|
status=(str(t.status.value) if hasattr(t, "status") and t.status is not None else None),
|
||||||
|
note=(t.note or None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if "booking" in want:
|
||||||
|
bookings = (
|
||||||
|
db.query(Booking)
|
||||||
|
.filter(Booking.user_id == current_user.id)
|
||||||
|
.order_by(Booking.id.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for b in bookings:
|
||||||
|
total = getattr(b, "total_cents", None)
|
||||||
|
if total is None:
|
||||||
|
price = getattr(b, "price_cents", None)
|
||||||
|
if price is None and hasattr(b, "product") and getattr(b, "product") is not None:
|
||||||
|
price = getattr(b.product, "price_cents", 0)
|
||||||
|
total = (price or 0) * (getattr(b, "amount", 1) or 1)
|
||||||
|
|
||||||
|
rows.append(
|
||||||
|
LedgerItem(
|
||||||
|
id=f"booking-{b.id}",
|
||||||
|
type="booking",
|
||||||
|
amount_cents=-int(total or 0), # Buchungen als negative Werte
|
||||||
|
created_at=(b.created_at.isoformat() if getattr(b, "created_at", None) else ""),
|
||||||
|
product_id=getattr(b, "product_id", None),
|
||||||
|
product_name=(getattr(b.product, "name", None) if hasattr(b, "product") and getattr(b, "product") is not None else None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# global sortieren und erst dann paginieren
|
||||||
|
rows.sort(key=lambda r: r.created_at, reverse=True)
|
||||||
|
return rows[offset : offset + limit]
|
||||||
|
|
||||||
|
# ------- ab hier NEU: auf Modulebene, nicht eingerückt! -------
|
||||||
|
|
||||||
|
class LedgerAdd(BaseModel):
|
||||||
|
amount_cents: int # darf +/− sein (Korrektur/Einlage)
|
||||||
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
@router.post("/", response_model=LedgerItem)
|
||||||
|
def add_ledger_entry(
|
||||||
|
payload: LedgerAdd = Body(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
# Topup erzeugen (als Kassenbewegung)
|
||||||
|
try:
|
||||||
|
from app.models.topup import TopupStatus # falls Enum existiert
|
||||||
|
status_value = TopupStatus.confirmed
|
||||||
|
except Exception:
|
||||||
|
status_value = "confirmed"
|
||||||
|
|
||||||
|
t = Topup(
|
||||||
|
user_id=current_user.id,
|
||||||
|
amount_cents=int(payload.amount_cents or 0),
|
||||||
|
note=(payload.note or "Manuelle Kassenkorrektur"),
|
||||||
|
status=status_value,
|
||||||
|
)
|
||||||
|
db.add(t)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(t)
|
||||||
|
|
||||||
|
return LedgerItem(
|
||||||
|
id=f"topup-{t.id}",
|
||||||
|
type="topup",
|
||||||
|
amount_cents=int(t.amount_cents or 0),
|
||||||
|
created_at=(t.created_at.isoformat() if getattr(t, "created_at", None) else ""),
|
||||||
|
status=(str(t.status.value) if hasattr(t, "status") and t.status is not None else "confirmed"),
|
||||||
|
note=(t.note or None),
|
||||||
|
)
|
79
apps/backend/app/api/paypal_ipn.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# app/api/paypal_ipn.py
|
||||||
|
import httpx, urllib.parse, os, re
|
||||||
|
from fastapi import APIRouter, Request, Response, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.models.topup import Topup, TopupStatus
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/paypal", tags=["paypal"])
|
||||||
|
|
||||||
|
PP_IPN_VERIFY_URL = "https://ipnpb.paypal.com/cgi-bin/webscr" # live
|
||||||
|
# Sandbox: "https://ipnpb.sandbox.paypal.com/cgi-bin/webscr"
|
||||||
|
|
||||||
|
def parse_custom(s: str):
|
||||||
|
# erwartet "topup:<id>|code:<CODE>"
|
||||||
|
data = {}
|
||||||
|
for part in (s or "").split("|"):
|
||||||
|
if ":" in part:
|
||||||
|
k,v = part.split(":",1)
|
||||||
|
data[k.strip()] = v.strip()
|
||||||
|
return data
|
||||||
|
|
||||||
|
@router.post("/ipn")
|
||||||
|
async def handle_ipn(request: Request, db: Session = Depends(get_db)):
|
||||||
|
raw = await request.body()
|
||||||
|
params = urllib.parse.parse_qs(raw.decode("utf-8"), keep_blank_values=True)
|
||||||
|
# 1) an PayPal zurückposten:
|
||||||
|
verify_payload = "cmd=_notify-validate&" + raw.decode("utf-8")
|
||||||
|
async with httpx.AsyncClient(timeout=15) as c:
|
||||||
|
vr = await c.post(PP_IPN_VERIFY_URL, data=verify_payload,
|
||||||
|
headers={"Content-Type":"application/x-www-form-urlencoded"})
|
||||||
|
if vr.text.strip() != "VERIFIED":
|
||||||
|
return Response("ignored", status_code=400)
|
||||||
|
|
||||||
|
payment_status = (params.get("payment_status",[None])[0] or "").lower()
|
||||||
|
receiver_email = (params.get("receiver_email",[None])[0] or "").lower()
|
||||||
|
mc_currency = params.get("mc_currency",[None])[0] or ""
|
||||||
|
mc_gross = params.get("mc_gross",[None])[0] or ""
|
||||||
|
custom = params.get("custom",[None])[0] or ""
|
||||||
|
item_name = params.get("item_name",[None])[0] or ""
|
||||||
|
txn_id = params.get("txn_id",[None])[0] or ""
|
||||||
|
|
||||||
|
# Grundvalidierungen
|
||||||
|
if payment_status != "completed":
|
||||||
|
return {"ok": True} # nur completed interessiert
|
||||||
|
# Optional: Empfänger-Email prüfen (gegen Config)
|
||||||
|
# if receiver_email != CONFIG.paypal_receiver.lower(): return Response("wrong receiver", 400)
|
||||||
|
if mc_currency != "EUR":
|
||||||
|
return Response("wrong currency", 400)
|
||||||
|
|
||||||
|
meta = parse_custom(custom)
|
||||||
|
topup_id = int(meta.get("topup") or 0)
|
||||||
|
code = meta.get("code") or ""
|
||||||
|
|
||||||
|
t = db.query(Topup).filter(Topup.id == topup_id).first()
|
||||||
|
if not t or t.status == TopupStatus.confirmed:
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
# Betrag prüfen
|
||||||
|
try:
|
||||||
|
cents_from_paypal = int(round(float(mc_gross.replace(",", ".")) * 100))
|
||||||
|
except Exception:
|
||||||
|
return Response("bad amount", 400)
|
||||||
|
if int(t.amount_cents or 0) != cents_from_paypal:
|
||||||
|
return Response("amount mismatch", 400)
|
||||||
|
|
||||||
|
# Sicherheits-Match: Code im item_name?
|
||||||
|
if code and code not in (item_name or ""):
|
||||||
|
# nicht hart abbrechen, aber flaggen wäre möglich
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Topup bestätigen + Saldo buchen (idempotent)
|
||||||
|
t.status = TopupStatus.confirmed
|
||||||
|
u = db.query(User).filter(User.id == t.user_id).first()
|
||||||
|
u.balance_cents = int(u.balance_cents or 0) + int(t.amount_cents or 0)
|
||||||
|
# Optional: t.paypal_txn_id = txn_id
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"ok": True}
|
54
apps/backend/app/api/products.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
from app.models.product import Product
|
||||||
|
from app.schemas.product import ProductCreate, ProductUpdate, ProductOut
|
||||||
|
from app.core.auth import get_current_user, requires_role
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/products", tags=["products"])
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@router.post("/", response_model=ProductOut, dependencies=[Depends(requires_role("manager", "admin"))])
|
||||||
|
def create_product(product: ProductCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
db_product = Product(**product.dict())
|
||||||
|
db.add(db_product)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_product)
|
||||||
|
return db_product
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[ProductOut])
|
||||||
|
def list_products(db: Session = Depends(get_db)):
|
||||||
|
return db.query(Product).all()
|
||||||
|
|
||||||
|
@router.get("/{product_id}", response_model=ProductOut)
|
||||||
|
def get_product(product_id: int, db: Session = Depends(get_db)):
|
||||||
|
product = db.query(Product).get(product_id)
|
||||||
|
if not product:
|
||||||
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
|
return product
|
||||||
|
|
||||||
|
@router.put("/{product_id}", response_model=ProductOut, dependencies=[Depends(requires_role("manager", "admin"))])
|
||||||
|
def update_product(product_id: int, product: ProductUpdate, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
db_product = db.query(Product).get(product_id)
|
||||||
|
if not db_product:
|
||||||
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
|
for key, value in product.dict(exclude_unset=True).items():
|
||||||
|
setattr(db_product, key, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_product)
|
||||||
|
return db_product
|
||||||
|
|
||||||
|
@router.delete("/{product_id}", status_code=204, dependencies=[Depends(requires_role("manager", "admin"))])
|
||||||
|
def delete_product(product_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
db_product = db.query(Product).get(product_id)
|
||||||
|
if not db_product:
|
||||||
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
|
db.delete(db_product)
|
||||||
|
db.commit()
|
135
apps/backend/app/api/profile.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException
|
||||||
|
from fastapi import status
|
||||||
|
from sqlalchemy.orm import Session as DBSession
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
from typing import Optional
|
||||||
|
from pydantic import BaseModel, EmailStr, field_validator
|
||||||
|
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
from app.core.auth import get_current_user
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/profile", tags=["profile"])
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# 1) Neuer PIN anfordern (Mock)
|
||||||
|
# -----------------------------
|
||||||
|
@router.post("/request-new-pin", status_code=status.HTTP_202_ACCEPTED)
|
||||||
|
def request_new_pin(current_user: User = Depends(get_current_user)):
|
||||||
|
return {"status": "queued", "message": "PIN request queued (mock)"}
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# 2) Profil-Avatar hochladen (persistiert avatar_url)
|
||||||
|
# -----------------------------
|
||||||
|
UPLOAD_DIR = Path("media/avatars")
|
||||||
|
|
||||||
|
@router.post("/avatar", status_code=status.HTTP_200_OK)
|
||||||
|
def upload_avatar(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
db: DBSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
filename = file.filename or "avatar"
|
||||||
|
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
|
||||||
|
if ext not in {"png", "jpg", "jpeg", "webp", "gif", ""}:
|
||||||
|
raise HTTPException(status_code=400, detail="Unsupported file type")
|
||||||
|
final_ext = ext if ext else "png"
|
||||||
|
|
||||||
|
dest = UPLOAD_DIR / f"{current_user.id}.{final_ext}"
|
||||||
|
with dest.open("wb") as out:
|
||||||
|
shutil.copyfileobj(file.file, out)
|
||||||
|
|
||||||
|
# User in diese Session laden
|
||||||
|
u = db.query(User).filter(User.id == current_user.id).first()
|
||||||
|
if not u:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
u.avatar_url = f"/media/avatars/{u.id}.{final_ext}"
|
||||||
|
db.commit()
|
||||||
|
db.refresh(u)
|
||||||
|
|
||||||
|
import time
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"avatar_url": f"{u.avatar_url}?t={int(time.time())}",
|
||||||
|
"path": str(dest),
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# 3) Eigene Basisdaten ändern (inkl. PayPal + Passwort)
|
||||||
|
# -----------------------------
|
||||||
|
class ProfileUpdate(BaseModel):
|
||||||
|
alias: Optional[str] = None
|
||||||
|
paypal_email: Optional[EmailStr] = None
|
||||||
|
visible_in_stats: Optional[bool] = None
|
||||||
|
public_stats: Optional[bool] = None
|
||||||
|
|
||||||
|
# Passwortwechsel
|
||||||
|
current_password: Optional[str] = None
|
||||||
|
new_password: Optional[str] = None
|
||||||
|
|
||||||
|
@field_validator("new_password")
|
||||||
|
@classmethod
|
||||||
|
def _min_len_pw(cls, v):
|
||||||
|
if v is not None and len(v) < 8:
|
||||||
|
raise ValueError("Password too short (min. 8)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@router.put("/me", response_model=dict)
|
||||||
|
def update_profile(
|
||||||
|
payload: ProfileUpdate,
|
||||||
|
db: DBSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
u = db.query(User).filter(User.id == current_user.id).first()
|
||||||
|
if not u:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
# Alias
|
||||||
|
if payload.alias is not None:
|
||||||
|
u.alias = payload.alias
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
# PayPal-Mail
|
||||||
|
if payload.paypal_email is not None:
|
||||||
|
u.paypal_email = str(payload.paypal_email).lower()
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
# Sichtbarkeit (Kompatibilität: beide Keys akzeptieren)
|
||||||
|
flag = payload.public_stats if payload.public_stats is not None else payload.visible_in_stats
|
||||||
|
if flag is not None:
|
||||||
|
u.public_stats = bool(flag)
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
# Passwortwechsel nur mit current_password
|
||||||
|
if payload.new_password is not None:
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
# Wenn ein Passwort gesetzt ist, muss current_password mitgeschickt werden
|
||||||
|
if getattr(u, "hashed_password", None):
|
||||||
|
if not payload.current_password:
|
||||||
|
raise HTTPException(status_code=400, detail="Current password required")
|
||||||
|
if not pwd.verify(payload.current_password, u.hashed_password):
|
||||||
|
raise HTTPException(status_code=400, detail="Current password invalid")
|
||||||
|
|
||||||
|
u.hashed_password = pwd.hash(payload.new_password)
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
db.commit()
|
||||||
|
db.refresh(u)
|
||||||
|
|
||||||
|
return {"status": "ok"}
|
20
apps/backend/app/api/sessions.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Request, Response
|
||||||
|
from sqlalchemy.orm import Session as DBSession
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.models.session import Session
|
||||||
|
from app.core.auth import SESSION_COOKIE_NAME, clear_session_cookie
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.delete("/sessions/me")
|
||||||
|
def delete_own_session(request: Request, response: Response, db: DBSession = Depends(get_db)):
|
||||||
|
token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||||
|
if not token:
|
||||||
|
clear_session_cookie(response)
|
||||||
|
return {"detail": "No active session"}
|
||||||
|
session = db.query(Session).filter_by(token=token).first()
|
||||||
|
if session:
|
||||||
|
db.delete(session)
|
||||||
|
db.commit()
|
||||||
|
clear_session_cookie(response)
|
||||||
|
return {"detail": "Session deleted"}
|
361
apps/backend/app/api/stats.py
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Optional, List, Dict, Any, Tuple
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy import func, and_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
# TZ: ZoneInfo ist vorhanden, aber es fehlen auf Windows oft die IANA-Daten.
|
||||||
|
try:
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
except Exception: # sehr defensive Fallbacks
|
||||||
|
ZoneInfo = None # type: ignore
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.auth import get_current_user, requires_role
|
||||||
|
from app.models.booking import Booking
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.product import Product
|
||||||
|
from app.models.delivery import Delivery # hat KEIN created_at bei dir
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/stats", tags=["stats"])
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------- Helpers -----------------
|
||||||
|
|
||||||
|
def _get_tz(tz_name: str) -> timezone:
|
||||||
|
"""
|
||||||
|
Liefert eine tzinfo. Auf Windows ohne tzdata fällt das auf UTC zurück,
|
||||||
|
statt eine ZoneInfoNotFoundError zu werfen.
|
||||||
|
"""
|
||||||
|
if ZoneInfo is not None:
|
||||||
|
try:
|
||||||
|
return ZoneInfo(tz_name) # type: ignore
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Fallback: UTC – korrekt, aber ohne CET/CEST-Offset.
|
||||||
|
return timezone.utc
|
||||||
|
|
||||||
|
|
||||||
|
def _to_naive_utc(dt: Optional[datetime]) -> Optional[datetime]:
|
||||||
|
"""
|
||||||
|
Konvertiert timezone-aware nach UTC und entfernt tzinfo.
|
||||||
|
Bei None -> None. Bei nativ-naiv wird unverändert zurückgegeben.
|
||||||
|
"""
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
return dt
|
||||||
|
return dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _delivery_time_column():
|
||||||
|
"""
|
||||||
|
Ermittelt die Zeitspalte des Delivery-Modells dynamisch.
|
||||||
|
Reihenfolge: created_at | timestamp | created | delivered_at
|
||||||
|
Gibt (Column, name) oder (None, None) zurück.
|
||||||
|
"""
|
||||||
|
for name in ("created_at", "timestamp", "created", "delivered_at"):
|
||||||
|
col = getattr(Delivery, name, None)
|
||||||
|
if col is not None:
|
||||||
|
return col, name
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def _last_delivery_at(db: Session) -> Optional[datetime]:
|
||||||
|
"""
|
||||||
|
Bestimmt den Zeitpunkt der letzten Lieferung anhand der vorhandenen Spalte.
|
||||||
|
Achtung: Gibt das zurück, was in der DB liegt (aware oder naiv).
|
||||||
|
"""
|
||||||
|
col, _ = _delivery_time_column()
|
||||||
|
if col is None:
|
||||||
|
return None
|
||||||
|
return db.query(func.max(col)).scalar()
|
||||||
|
|
||||||
|
|
||||||
|
def _period_bounds(db: Session, period: str, tz_name: str = "Europe/Berlin") -> Tuple[Optional[datetime], Optional[datetime], Optional[datetime]]:
|
||||||
|
"""
|
||||||
|
Liefert (from_utc_naive, to_utc_naive, last_delivery_time_original).
|
||||||
|
Booking.timestamp ist in deiner App naiv/UTC, daher filtern wir mit naiven UTC-Grenzen.
|
||||||
|
"""
|
||||||
|
tz = _get_tz(tz_name)
|
||||||
|
now_local = datetime.now(tz)
|
||||||
|
now_utc_naive = now_local.astimezone(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
last_delivery = _last_delivery_at(db)
|
||||||
|
|
||||||
|
if period == "last_delivery":
|
||||||
|
if last_delivery is None:
|
||||||
|
# Fallback: 30 Tage rückwärts
|
||||||
|
from_utc_naive = (now_local - timedelta(days=30)).astimezone(timezone.utc).replace(tzinfo=None)
|
||||||
|
else:
|
||||||
|
from_utc_naive = _to_naive_utc(last_delivery)
|
||||||
|
return (from_utc_naive, now_utc_naive, last_delivery)
|
||||||
|
|
||||||
|
if period == "ytd":
|
||||||
|
start_local = datetime(now_local.year, 1, 1, tzinfo=tz)
|
||||||
|
start_utc_naive = start_local.astimezone(timezone.utc).replace(tzinfo=None)
|
||||||
|
return (start_utc_naive, now_utc_naive, last_delivery)
|
||||||
|
|
||||||
|
# "all" -> keine untere Grenze
|
||||||
|
return (None, now_utc_naive, last_delivery)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------- Öffentliche Stat-Endpunkte (auth-pflichtig, aber nicht nur Admin) -----------------
|
||||||
|
|
||||||
|
@router.get("/meta")
|
||||||
|
def stats_meta(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_user),
|
||||||
|
tz: str = Query("Europe/Berlin")
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Metadaten für die Stats-Seite:
|
||||||
|
- categories: distinct Product.category (ohne NULL)
|
||||||
|
- last_delivery_at: ISO-Zeitpunkt der letzten Lieferung (falls vorhanden)
|
||||||
|
- visible_count: Nutzer mit public_stats==true & alias gesetzt & is_active==true
|
||||||
|
- hidden_count: aktive Nutzer minus visible_count
|
||||||
|
- now: Serverzeit in tz
|
||||||
|
"""
|
||||||
|
# Kategorien
|
||||||
|
cats = [
|
||||||
|
c for (c,) in db.query(Product.category)
|
||||||
|
.filter(Product.category.isnot(None))
|
||||||
|
.distinct()
|
||||||
|
.order_by(Product.category.asc())
|
||||||
|
.all()
|
||||||
|
]
|
||||||
|
|
||||||
|
# Letzte Lieferung – robust gegen fehlende Spalte
|
||||||
|
last_delivery_time = _last_delivery_at(db)
|
||||||
|
|
||||||
|
# Sichtbarkeit
|
||||||
|
visible_count = (
|
||||||
|
db.query(func.count(User.id))
|
||||||
|
.filter(
|
||||||
|
and_(
|
||||||
|
User.is_active.is_(True),
|
||||||
|
User.alias.isnot(None),
|
||||||
|
func.length(func.trim(User.alias)) > 0,
|
||||||
|
getattr(User, "public_stats", False) == True, # robust falls Migration noch nicht durch
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
active_count = db.query(func.count(User.id)).filter(User.is_active.is_(True)).scalar() or 0
|
||||||
|
|
||||||
|
now_local = datetime.now(_get_tz(tz))
|
||||||
|
return {
|
||||||
|
"categories": cats,
|
||||||
|
"last_delivery_at": last_delivery_time.isoformat() if last_delivery_time else None,
|
||||||
|
"visible_count": int(visible_count),
|
||||||
|
"hidden_count": int(max(0, active_count - visible_count)),
|
||||||
|
"now": now_local.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/top-drinkers")
|
||||||
|
def top_drinkers(
|
||||||
|
period: str = Query("last_delivery", pattern="^(last_delivery|ytd|all)$"),
|
||||||
|
category: str = Query("all"),
|
||||||
|
limit: int = Query(5, ge=1, le=50),
|
||||||
|
tz: str = Query("Europe/Berlin"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_user),
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Top-Trinker je Zeitraum und optional Kategorie.
|
||||||
|
Aggregation: Summe Booking.amount (Stückzahl), zusätzlich count(*) und SUM(total_cents).
|
||||||
|
Nur Nutzer mit Opt-in (public_stats) + Alias + aktiv.
|
||||||
|
"""
|
||||||
|
from_dt, to_dt, _ = _period_bounds(db, period, tz)
|
||||||
|
|
||||||
|
q = (
|
||||||
|
db.query(
|
||||||
|
User.id.label("user_id"),
|
||||||
|
User.alias.label("alias"),
|
||||||
|
User.avatar_url.label("avatar_url"),
|
||||||
|
func.coalesce(func.sum(Booking.amount), 0).label("amount_sum"),
|
||||||
|
func.count(Booking.id).label("count"),
|
||||||
|
func.coalesce(func.sum(Booking.total_cents), 0).label("revenue_cents"),
|
||||||
|
)
|
||||||
|
.join(User, User.id == Booking.user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if category != "all":
|
||||||
|
q = q.join(Product, Product.id == Booking.product_id).filter(Product.category == category)
|
||||||
|
|
||||||
|
if from_dt is not None:
|
||||||
|
q = q.filter(Booking.timestamp >= from_dt)
|
||||||
|
if to_dt is not None:
|
||||||
|
q = q.filter(Booking.timestamp <= to_dt)
|
||||||
|
|
||||||
|
# Sichtbarkeitsregeln – robust falls Spalte noch nicht migriert:
|
||||||
|
public_stats_col = getattr(User, "public_stats", None)
|
||||||
|
conds = [
|
||||||
|
User.is_active.is_(True),
|
||||||
|
User.alias.isnot(None),
|
||||||
|
func.length(func.trim(User.alias)) > 0,
|
||||||
|
]
|
||||||
|
if public_stats_col is not None:
|
||||||
|
conds.append(public_stats_col.is_(True))
|
||||||
|
|
||||||
|
q = q.filter(and_(*conds))
|
||||||
|
|
||||||
|
q = q.group_by(User.id, User.alias, User.avatar_url).order_by(func.coalesce(func.sum(Booking.amount), 0).desc()).limit(limit)
|
||||||
|
rows = q.all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"user_id": r.user_id,
|
||||||
|
"alias": r.alias,
|
||||||
|
"avatar_url": r.avatar_url,
|
||||||
|
"amount_sum": int(r.amount_sum or 0),
|
||||||
|
"count": int(r.count or 0),
|
||||||
|
"revenue_cents": int(r.revenue_cents or 0),
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/product-share")
|
||||||
|
def product_share(
|
||||||
|
period: str = Query("last_delivery", pattern="^(last_delivery|ytd|all)$"),
|
||||||
|
tz: str = Query("Europe/Berlin"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_user),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Produktverteilung (Kreisdiagramm): COUNT(Booking.id) je Produkt im Zeitraum.
|
||||||
|
Unabhängig vom Opt-in, da keine personenbezogenen Daten.
|
||||||
|
"""
|
||||||
|
from_dt, to_dt, _ = _period_bounds(db, period, tz)
|
||||||
|
|
||||||
|
q = (
|
||||||
|
db.query(
|
||||||
|
Product.id.label("product_id"),
|
||||||
|
Product.name.label("product_name"),
|
||||||
|
func.count(Booking.id).label("count_rows"),
|
||||||
|
)
|
||||||
|
.join(Product, Product.id == Booking.product_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if from_dt is not None:
|
||||||
|
q = q.filter(Booking.timestamp >= from_dt)
|
||||||
|
if to_dt is not None:
|
||||||
|
q = q.filter(Booking.timestamp <= to_dt)
|
||||||
|
|
||||||
|
q = q.group_by(Product.id, Product.name).order_by(func.count(Booking.id).desc())
|
||||||
|
items = q.all()
|
||||||
|
|
||||||
|
total = sum(int(r.count_rows or 0) for r in items)
|
||||||
|
return {
|
||||||
|
"total": int(total),
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"product_id": r.product_id,
|
||||||
|
"product_name": r.product_name,
|
||||||
|
"count": int(r.count_rows or 0),
|
||||||
|
}
|
||||||
|
for r in items
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------- Bestehende Admin-Stats (weiterhin verfügbar) -----------------
|
||||||
|
|
||||||
|
@router.get("/summary", dependencies=[Depends(requires_role("manager", "admin"))])
|
||||||
|
def summary(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
users = db.query(func.count(User.id)).scalar() or 0
|
||||||
|
products = db.query(func.count(Product.id)).scalar() or 0
|
||||||
|
bookings = db.query(func.count(Booking.id)).scalar() or 0
|
||||||
|
total_revenue = db.query(func.coalesce(func.sum(Booking.total_cents), 0)).scalar() or 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"num_users": users,
|
||||||
|
"num_products": products,
|
||||||
|
"num_bookings": bookings,
|
||||||
|
"total_revenue_cents": int(total_revenue),
|
||||||
|
"my_balance_cents": int(user.balance_cents or 0),
|
||||||
|
"stock_overview": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/revenue-summary", dependencies=[Depends(requires_role("manager", "admin"))])
|
||||||
|
def revenue_summary(db: Session = Depends(get_db), _: User = Depends(get_current_user)):
|
||||||
|
total_cents = db.query(func.coalesce(func.sum(Booking.total_cents), 0)).scalar() or 0
|
||||||
|
|
||||||
|
# dynamische Delivery-Zeitspalte verwenden
|
||||||
|
last_delivery_time = _last_delivery_at(db)
|
||||||
|
|
||||||
|
if last_delivery_time:
|
||||||
|
since_last_cents = (
|
||||||
|
db.query(func.coalesce(func.sum(Booking.total_cents), 0))
|
||||||
|
.filter(Booking.timestamp > _to_naive_utc(last_delivery_time))
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
since_last_cents = total_cents
|
||||||
|
|
||||||
|
return {
|
||||||
|
"revenue_total_cents": int(total_cents),
|
||||||
|
"revenue_since_last_delivery_cents": int(since_last_cents),
|
||||||
|
"last_delivery_at": last_delivery_time.isoformat() if last_delivery_time else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# (optionale Alt-Endpunkte, falls noch genutzt)
|
||||||
|
@router.get("/consumption-per-user", dependencies=[Depends(requires_role("manager", "admin"))])
|
||||||
|
def consumption_per_user(db: Session = Depends(get_db), _: User = Depends(get_current_user)):
|
||||||
|
rows = (
|
||||||
|
db.query(
|
||||||
|
User.id.label("user_id"),
|
||||||
|
User.name,
|
||||||
|
func.coalesce(func.sum(Booking.total_cents), 0).label("total_cents"),
|
||||||
|
)
|
||||||
|
.outerjoin(Booking, Booking.user_id == User.id)
|
||||||
|
.group_by(User.id, User.name)
|
||||||
|
.order_by(func.coalesce(func.sum(Booking.total_cents), 0).desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [{"user_id": r.user_id, "name": r.name, "total_cents": int(r.total_cents or 0)} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/consumption-per-product", dependencies=[Depends(requires_role("manager", "admin"))])
|
||||||
|
def consumption_per_product(db: Session = Depends(get_db), _: User = Depends(get_current_user)):
|
||||||
|
rows = (
|
||||||
|
db.query(
|
||||||
|
Product.id.label("product_id"),
|
||||||
|
Product.name.label("name"),
|
||||||
|
func.count(Booking.id).label("count"),
|
||||||
|
)
|
||||||
|
.outerjoin(Booking, Booking.product_id == Product.id)
|
||||||
|
.group_by(Product.id, Product.name)
|
||||||
|
.order_by(func.count(Booking.id).desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [{"product_id": r.product_id, "name": r.name, "count": int(r.count or 0)} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/monthly-ranking", dependencies=[Depends(requires_role("manager", "admin"))])
|
||||||
|
def monthly_ranking(
|
||||||
|
year: Optional[int] = Query(None),
|
||||||
|
month: Optional[int] = Query(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
q = (
|
||||||
|
db.query(
|
||||||
|
User.id.label("user_id"),
|
||||||
|
User.name,
|
||||||
|
func.coalesce(func.sum(Booking.total_cents), 0).label("total_cents"),
|
||||||
|
)
|
||||||
|
.join(User, User.id == Booking.user_id)
|
||||||
|
.group_by(User.id, User.name)
|
||||||
|
.order_by(func.coalesce(func.sum(Booking.total_cents), 0).desc())
|
||||||
|
.limit(10)
|
||||||
|
)
|
||||||
|
return [{"user_id": r.user_id, "name": r.name, "total_cents": int(r.total_cents or 0)} for r in q.all()]
|
203
apps/backend/app/api/topups.py
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
# app/api/topups.py
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List, Optional, Literal
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.auth import get_current_user, requires_role
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.topup import Topup, TopupStatus as DBTopupStatus
|
||||||
|
from app.schemas.topup import TopupOut, TopupCreate # Pydantic v2 Schemas
|
||||||
|
|
||||||
|
# Optionales Audit-Log – nur nutzen, wenn vorhanden
|
||||||
|
try:
|
||||||
|
from app.models.audit_log import AuditLog, AuditAction
|
||||||
|
HAS_AUDIT = True
|
||||||
|
except Exception:
|
||||||
|
AuditLog = None
|
||||||
|
AuditAction = None
|
||||||
|
HAS_AUDIT = False
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/topups", tags=["topups"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------
|
||||||
|
# CREATE (User oder Admin)
|
||||||
|
# ---------------------------
|
||||||
|
@router.post("/", response_model=TopupOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_topup(
|
||||||
|
topup: TopupCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Legt ein Topup an. amount_cents ist Pflicht. user_id ist optional; wenn nicht gesetzt,
|
||||||
|
wird der aktuelle User verwendet.
|
||||||
|
"""
|
||||||
|
payload = topup.model_dump() # Pydantic v2
|
||||||
|
|
||||||
|
# user_id für User-Flow ergänzen
|
||||||
|
user_id = payload.get("user_id") or current_user.id
|
||||||
|
amount_cents = int(payload.get("amount_cents") or 0)
|
||||||
|
if amount_cents <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="amount_cents must be > 0")
|
||||||
|
|
||||||
|
db_topup = Topup(
|
||||||
|
user_id=user_id,
|
||||||
|
amount_cents=amount_cents,
|
||||||
|
paypal_email=payload.get("paypal_email"),
|
||||||
|
note=payload.get("note"),
|
||||||
|
# status default: pending (siehe Model)
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(db_topup)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_topup)
|
||||||
|
|
||||||
|
if HAS_AUDIT:
|
||||||
|
db.add(
|
||||||
|
AuditLog(
|
||||||
|
user_id=db_topup.user_id,
|
||||||
|
action=getattr(AuditAction, "topup_create", "topup_create"),
|
||||||
|
amount_cents=db_topup.amount_cents,
|
||||||
|
info=f"created by {current_user.id}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return db_topup
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------
|
||||||
|
# LIST (Admin/Manager, gefiltert)
|
||||||
|
# ---------------------------
|
||||||
|
@router.get("/", response_model=List[TopupOut], dependencies=[Depends(requires_role("manager", "admin"))])
|
||||||
|
def list_topups(
|
||||||
|
user_id: Optional[int] = Query(default=None),
|
||||||
|
status_filter: Optional[str] = Query(default=None, description="pending|confirmed|rejected"),
|
||||||
|
limit: int = Query(default=100, ge=1, le=1000),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
q = db.query(Topup)
|
||||||
|
if user_id is not None:
|
||||||
|
q = q.filter(Topup.user_id == user_id)
|
||||||
|
if status_filter:
|
||||||
|
try:
|
||||||
|
enum_val = DBTopupStatus(status_filter)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="invalid status_filter")
|
||||||
|
q = q.filter(Topup.status == enum_val)
|
||||||
|
q = q.order_by(Topup.id.desc()).limit(limit).offset(offset)
|
||||||
|
return q.all()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------
|
||||||
|
# LIST OWN (alle eingeloggten)
|
||||||
|
# ---------------------------
|
||||||
|
@router.get("/me", response_model=List[TopupOut])
|
||||||
|
def list_my_topups(
|
||||||
|
limit: int = Query(default=100, ge=1, le=1000),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
q = (
|
||||||
|
db.query(Topup)
|
||||||
|
.filter(Topup.user_id == current_user.id)
|
||||||
|
.order_by(Topup.id.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
return q.all()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------
|
||||||
|
# STATUS ändern (Admin/Manager)
|
||||||
|
# ---------------------------
|
||||||
|
class TopupStatusIn(BaseModel):
|
||||||
|
status: Literal["confirmed", "rejected"]
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{topup_id}/status", dependencies=[Depends(requires_role("manager", "admin"))])
|
||||||
|
def patch_topup_status(
|
||||||
|
topup_id: int,
|
||||||
|
payload: TopupStatusIn,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
t = db.query(Topup).filter(Topup.id == topup_id).first()
|
||||||
|
if not t:
|
||||||
|
raise HTTPException(status_code=404, detail="Topup not found")
|
||||||
|
|
||||||
|
# robustes Enum-Handling
|
||||||
|
old_enum = t.status if isinstance(t.status, DBTopupStatus) else DBTopupStatus(str(t.status))
|
||||||
|
try:
|
||||||
|
new_enum = DBTopupStatus(payload.status)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="invalid status")
|
||||||
|
|
||||||
|
if old_enum == new_enum:
|
||||||
|
return {"status": "ok", "topup_id": t.id, "new_status": old_enum.value}
|
||||||
|
|
||||||
|
# Status setzen
|
||||||
|
t.status = new_enum
|
||||||
|
|
||||||
|
# Balance nur bei Übergang zu 'confirmed' buchen (idempotent)
|
||||||
|
if new_enum == DBTopupStatus.confirmed and old_enum != DBTopupStatus.confirmed:
|
||||||
|
u = db.query(User).filter(User.id == t.user_id).first()
|
||||||
|
if not u:
|
||||||
|
raise HTTPException(status_code=404, detail="User for topup not found")
|
||||||
|
|
||||||
|
before = int(u.balance_cents or 0)
|
||||||
|
u.balance_cents = before + int(t.amount_cents or 0)
|
||||||
|
|
||||||
|
if HAS_AUDIT:
|
||||||
|
db.add(
|
||||||
|
AuditLog(
|
||||||
|
user_id=u.id,
|
||||||
|
action=getattr(AuditAction, "topup_confirmed", "topup_confirmed"),
|
||||||
|
amount_cents=int(t.amount_cents or 0),
|
||||||
|
old_balance_cents=before,
|
||||||
|
new_balance_cents=u.balance_cents,
|
||||||
|
info=f"topup:{t.id} confirmed by {admin.id}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# (Kein automatisches Reversal bei Wechsel zu rejected)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(t)
|
||||||
|
return {"status": "ok", "topup_id": t.id, "new_status": t.status.value}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------
|
||||||
|
# DELETE (Admin/Manager)
|
||||||
|
# ---------------------------
|
||||||
|
@router.delete("/{topup_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(requires_role("manager", "admin"))])
|
||||||
|
def delete_topup(
|
||||||
|
topup_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
t = db.query(Topup).filter(Topup.id == topup_id).first()
|
||||||
|
if not t:
|
||||||
|
raise HTTPException(status_code=404, detail="Topup not found")
|
||||||
|
|
||||||
|
db.delete(t)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
if HAS_AUDIT:
|
||||||
|
db.add(
|
||||||
|
AuditLog(
|
||||||
|
user_id=t.user_id,
|
||||||
|
action=getattr(AuditAction, "topup_delete", "topup_delete"),
|
||||||
|
amount_cents=int(t.amount_cents or 0),
|
||||||
|
info=f"topup:{t.id} deleted by {admin.id}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return None
|
127
apps/backend/app/api/transactions.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List, Literal, TypedDict
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.auth import get_current_user, requires_role
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.topup import Topup
|
||||||
|
from app.models.booking import Booking
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/transactions", tags=["transactions"])
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionOut(TypedDict):
|
||||||
|
id: str
|
||||||
|
type: Literal["topup", "booking"]
|
||||||
|
amount_cents: int
|
||||||
|
created_at: str # ISO
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
def list_my_transactions(
|
||||||
|
limit: int = Query(100, ge=1, le=1000),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Kombiniert Topups (+) und Buchungen (−) des aktuellen Users.
|
||||||
|
"""
|
||||||
|
topups = (
|
||||||
|
db.query(Topup)
|
||||||
|
.filter(Topup.user_id == current_user.id)
|
||||||
|
.order_by(Topup.id.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
bookings = (
|
||||||
|
db.query(Booking)
|
||||||
|
.filter(Booking.user_id == current_user.id)
|
||||||
|
.order_by(Booking.id.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
tx: List[TransactionOut] = []
|
||||||
|
|
||||||
|
for t in topups:
|
||||||
|
tx.append(
|
||||||
|
{
|
||||||
|
"id": f"topup-{t.id}",
|
||||||
|
"type": "topup",
|
||||||
|
"amount_cents": int(t.amount_cents or 0),
|
||||||
|
"created_at": t.created_at.isoformat() if hasattr(t, "created_at") and t.created_at else "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for b in bookings:
|
||||||
|
# Wenn du Booking.total_cents hast, nutze das. Sonst product.price_cents * amount.
|
||||||
|
total = getattr(b, "total_cents", None)
|
||||||
|
if total is None:
|
||||||
|
price = getattr(b, "price_cents", None)
|
||||||
|
if price is None and hasattr(b, "product") and b.product:
|
||||||
|
price = getattr(b.product, "price_cents", 0)
|
||||||
|
total = (price or 0) * (getattr(b, "amount", 1) or 1)
|
||||||
|
tx.append(
|
||||||
|
{
|
||||||
|
"id": f"booking-{b.id}",
|
||||||
|
"type": "booking",
|
||||||
|
"amount_cents": -int(total or 0), # Buchungen als negative Beträge
|
||||||
|
"created_at": b.created_at.isoformat() if hasattr(b, "created_at") and b.created_at else "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Absteigend nach Datum
|
||||||
|
tx.sort(key=lambda x: x["created_at"], reverse=True)
|
||||||
|
# einfache Paginierung nach Sortierung (optional): tx = tx[offset:offset+limit]
|
||||||
|
return tx
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", dependencies=[Depends(requires_role("manager", "admin"))])
|
||||||
|
def list_all_transactions_admin(
|
||||||
|
limit: int = Query(200, ge=1, le=2000),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Admin/Manager: kombinierte Übersicht (einfach, nicht performant für große Datenmengen).
|
||||||
|
"""
|
||||||
|
topups = (
|
||||||
|
db.query(Topup).order_by(Topup.id.desc()).limit(limit).offset(offset).all()
|
||||||
|
)
|
||||||
|
bookings = (
|
||||||
|
db.query(Booking).order_by(Booking.id.desc()).limit(limit).offset(offset).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
tx: List[TransactionOut] = []
|
||||||
|
for t in topups:
|
||||||
|
tx.append(
|
||||||
|
{
|
||||||
|
"id": f"topup-{t.id}",
|
||||||
|
"type": "topup",
|
||||||
|
"amount_cents": int(t.amount_cents or 0),
|
||||||
|
"created_at": t.created_at.isoformat() if hasattr(t, "created_at") and t.created_at else "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for b in bookings:
|
||||||
|
total = getattr(b, "total_cents", None)
|
||||||
|
if total is None:
|
||||||
|
price = getattr(b, "price_cents", None)
|
||||||
|
if price is None and hasattr(b, "product") and b.product:
|
||||||
|
price = getattr(b.product, "price_cents", 0)
|
||||||
|
total = (price or 0) * (getattr(b, "amount", 1) or 1)
|
||||||
|
tx.append(
|
||||||
|
{
|
||||||
|
"id": f"booking-{b.id}",
|
||||||
|
"type": "booking",
|
||||||
|
"amount_cents": -int(total or 0),
|
||||||
|
"created_at": b.created_at.isoformat() if hasattr(b, "created_at") and b.created_at else "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
tx.sort(key=lambda x: x["created_at"], reverse=True)
|
||||||
|
return tx
|
258
apps/backend/app/api/users.py
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Query, Body
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import func, or_, asc, desc
|
||||||
|
from typing import List, Optional
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from pydantic import BaseModel, constr, conint
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.user import UserOut, UserCreate, UserUpdate
|
||||||
|
from app.core.auth import get_current_user, requires_role_relaxed, requires_role_mgmt
|
||||||
|
|
||||||
|
# NEU: Topup-Model importieren, damit Admin-Gutschriften als Topup geloggt werden
|
||||||
|
from app.models.topup import Topup, TopupStatus
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/users", tags=["users"])
|
||||||
|
pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
def get_db_sess():
|
||||||
|
db = get_db()
|
||||||
|
# get_db ist ein generator → FastAPI injiziert automatisch.
|
||||||
|
# falls du eine SessionLocal-Funktion hast, nutze die hier analog.
|
||||||
|
return db
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[UserOut], dependencies=[Depends(requires_role_relaxed("manager","admin"))])
|
||||||
|
def list_users(
|
||||||
|
q: Optional[str] = Query(default=None, description="Suche in Name/E-Mail/Alias"),
|
||||||
|
role: Optional[str] = Query(default=None, regex="^(user|manager|admin)$"),
|
||||||
|
active: Optional[bool] = Query(default=None),
|
||||||
|
balance_lt: Optional[int] = Query(default=None, description="Kontostand < Wert (Cent)"),
|
||||||
|
limit: int = Query(default=25, ge=1, le=200),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
sort: str = Query(default="name", description="name|email|role|balance_cents|is_active"),
|
||||||
|
order: str = Query(default="asc", regex="^(asc|desc)$"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
qy = db.query(User)
|
||||||
|
if q:
|
||||||
|
like = f"%{q.lower()}%"
|
||||||
|
qy = qy.filter(or_(
|
||||||
|
func.lower(User.name).like(like),
|
||||||
|
func.lower(User.email).like(like),
|
||||||
|
func.lower(func.coalesce(User.alias, "")).like(like),
|
||||||
|
))
|
||||||
|
if role:
|
||||||
|
qy = qy.filter(User.role == role)
|
||||||
|
if active is not None:
|
||||||
|
qy = qy.filter(User.is_active == active)
|
||||||
|
if balance_lt is not None:
|
||||||
|
qy = qy.filter((User.balance_cents != None) & (User.balance_cents < balance_lt))
|
||||||
|
|
||||||
|
sort_map = {
|
||||||
|
"name": User.name,
|
||||||
|
"email": User.email,
|
||||||
|
"role": User.role,
|
||||||
|
"balance_cents": User.balance_cents,
|
||||||
|
"is_active": User.is_active,
|
||||||
|
}
|
||||||
|
col = sort_map.get(sort, User.name)
|
||||||
|
qy = qy.order_by(asc(col) if order == "asc" else desc(col))
|
||||||
|
|
||||||
|
return qy.limit(limit).offset(offset).all()
|
||||||
|
|
||||||
|
@router.get("/{user_id}", response_model=UserOut, dependencies=[Depends(requires_role_relaxed("manager","admin"))])
|
||||||
|
def get_user(user_id: int, db: Session = Depends(get_db), _: User = Depends(get_current_user)):
|
||||||
|
u = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not u:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
return u
|
||||||
|
|
||||||
|
@router.post("/", response_model=UserOut, status_code=status.HTTP_201_CREATED, dependencies=[Depends(requires_role_mgmt("manager","admin"))])
|
||||||
|
def create_user(payload: UserCreate, db: Session = Depends(get_db), admin: User = Depends(get_current_user)):
|
||||||
|
if db.query(User).filter(User.email == payload.email).first():
|
||||||
|
raise HTTPException(status_code=409, detail="E-Mail already exists")
|
||||||
|
if payload.alias and db.query(User).filter(User.alias == payload.alias).first():
|
||||||
|
raise HTTPException(status_code=409, detail="Alias already exists")
|
||||||
|
u = User(
|
||||||
|
name=payload.name,
|
||||||
|
email=payload.email,
|
||||||
|
hashed_password=pwd.hash(payload.password),
|
||||||
|
hashed_pin=pwd.hash(payload.pin),
|
||||||
|
alias=payload.alias,
|
||||||
|
paypal_email=payload.paypal_email,
|
||||||
|
role=payload.role,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(u); db.commit(); db.refresh(u)
|
||||||
|
return u
|
||||||
|
|
||||||
|
@router.patch("/{user_id}", response_model=UserOut, dependencies=[Depends(requires_role_mgmt("manager","admin"))])
|
||||||
|
def update_user(user_id: int, payload: UserUpdate, db: Session = Depends(get_db), admin: User = Depends(get_current_user)):
|
||||||
|
u = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not u:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
if payload.email is not None:
|
||||||
|
if db.query(User).filter(User.email == payload.email, User.id != user_id).first():
|
||||||
|
raise HTTPException(status_code=409, detail="E-Mail already exists")
|
||||||
|
u.email = payload.email
|
||||||
|
if payload.alias is not None:
|
||||||
|
if db.query(User).filter(User.alias == payload.alias, User.id != user_id).first():
|
||||||
|
raise HTTPException(status_code=409, detail="Alias already exists")
|
||||||
|
u.alias = payload.alias
|
||||||
|
if payload.name is not None:
|
||||||
|
u.name = payload.name
|
||||||
|
if payload.password is not None:
|
||||||
|
u.hashed_password = pwd.hash(payload.password)
|
||||||
|
if payload.pin is not None:
|
||||||
|
u.hashed_pin = pwd.hash(payload.pin)
|
||||||
|
if payload.paypal_email is not None:
|
||||||
|
u.paypal_email = payload.paypal_email
|
||||||
|
if payload.role is not None:
|
||||||
|
u.role = payload.role
|
||||||
|
if payload.is_active is not None:
|
||||||
|
u.is_active = payload.is_active
|
||||||
|
|
||||||
|
db.commit(); db.refresh(u)
|
||||||
|
return u
|
||||||
|
|
||||||
|
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(requires_role_mgmt("manager","admin"))])
|
||||||
|
def delete_user(user_id: int, db: Session = Depends(get_db), admin: User = Depends(get_current_user)):
|
||||||
|
u = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not u:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
db.delete(u); db.commit()
|
||||||
|
return None
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# Sicherheitsaktionen
|
||||||
|
# -----------------------------
|
||||||
|
|
||||||
|
class SetPinIn(BaseModel):
|
||||||
|
pin: constr(min_length=6, max_length=6)
|
||||||
|
|
||||||
|
class SetPasswordIn(BaseModel):
|
||||||
|
password: constr(min_length=8)
|
||||||
|
|
||||||
|
@router.post("/{user_id}/set-pin", dependencies=[Depends(requires_role_mgmt("manager","admin"))])
|
||||||
|
def set_pin(user_id: int, payload: SetPinIn, db: Session = Depends(get_db), admin: User = Depends(get_current_user)):
|
||||||
|
u = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not u: raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
if admin.role == "manager" and u.role == "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="Managers cannot modify admin credentials")
|
||||||
|
u.hashed_pin = pwd.hash(payload.pin); db.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
@router.post("/{user_id}/set-password", dependencies=[Depends(requires_role_mgmt("manager","admin"))])
|
||||||
|
def set_password(user_id: int, payload: SetPasswordIn, db: Session = Depends(get_db), admin: User = Depends(get_current_user)):
|
||||||
|
u = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not u: raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
if admin.role == "manager" and u.role == "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="Managers cannot modify admin credentials")
|
||||||
|
u.hashed_password = pwd.hash(payload.password); db.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
class BalanceAdjustIn(BaseModel):
|
||||||
|
amount_cents: conint(strict=True)
|
||||||
|
reason: constr(min_length=1, max_length=500)
|
||||||
|
|
||||||
|
@router.post("/{user_id}/adjust-balance", dependencies=[Depends(requires_role_mgmt("manager","admin"))])
|
||||||
|
def adjust_balance(user_id: int, payload: BalanceAdjustIn, db: Session = Depends(get_db), admin: User = Depends(get_current_user)):
|
||||||
|
"""
|
||||||
|
Admin/Manager passt das Guthaben an.
|
||||||
|
- Betrag != 0 erforderlich.
|
||||||
|
- Bei positiver Anpassung wird zusätzlich ein Topup mit status=confirmed und note=reason erstellt,
|
||||||
|
damit es in /topups/me (und der TransactionPage) sichtbar ist.
|
||||||
|
"""
|
||||||
|
amount = int(payload.amount_cents or 0)
|
||||||
|
if amount == 0:
|
||||||
|
raise HTTPException(status_code=400, detail="amount_cents must be non-zero")
|
||||||
|
|
||||||
|
u = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not u:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
if admin.role == "manager" and u.role == "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="Managers cannot adjust admin accounts")
|
||||||
|
|
||||||
|
before = int(u.balance_cents or 0)
|
||||||
|
u.balance_cents = before + amount
|
||||||
|
|
||||||
|
created_topup_id: Optional[int] = None
|
||||||
|
|
||||||
|
# Nur bei Gutschrift (+) zusätzlich Topup schreiben (mit Notiz = reason).
|
||||||
|
if amount > 0:
|
||||||
|
t = Topup(
|
||||||
|
user_id=u.id,
|
||||||
|
amount_cents=amount,
|
||||||
|
status=TopupStatus.confirmed, # direkt bestätigt
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
confirmed_at=datetime.utcnow(),
|
||||||
|
note=payload.reason.strip() if payload.reason else None,
|
||||||
|
)
|
||||||
|
db.add(t)
|
||||||
|
db.flush() # ID holen, ohne Zwischen-Commit
|
||||||
|
created_topup_id = t.id
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(u)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"user_id": u.id,
|
||||||
|
"old_balance_cents": before,
|
||||||
|
"new_balance_cents": u.balance_cents,
|
||||||
|
"created_topup_id": created_topup_id, # nur bei positiver Anpassung gesetzt
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# FAVORITES: GET / PUT / PATCH
|
||||||
|
# -----------------------------
|
||||||
|
|
||||||
|
def _ensure_can_edit(target_user_id: int, actor: User):
|
||||||
|
"""Eigenen Datensatz immer; sonst nur manager/admin."""
|
||||||
|
if actor.id != target_user_id and str(actor.role) not in {"manager", "admin"}:
|
||||||
|
raise HTTPException(status_code=403, detail="Not allowed")
|
||||||
|
|
||||||
|
@router.get("/{user_id}/favorites", response_model=List[int])
|
||||||
|
def get_user_favorites(
|
||||||
|
user_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
_ensure_can_edit(user_id, current)
|
||||||
|
u = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not u:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
return list(u.favorites or [])
|
||||||
|
|
||||||
|
@router.put("/{user_id}/favorites", response_model=List[int])
|
||||||
|
def replace_user_favorites(
|
||||||
|
user_id: int,
|
||||||
|
favorites: List[int] = Body(..., embed=False, description="Array von Produkt-IDs"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
_ensure_can_edit(user_id, current)
|
||||||
|
u = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not u:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
# Sanitizing: nur eindeutige, positive ints
|
||||||
|
clean = sorted({int(x) for x in favorites if isinstance(x, int) and x > 0})
|
||||||
|
u.favorites = clean
|
||||||
|
db.add(u)
|
||||||
|
db.commit()
|
||||||
|
return clean
|
||||||
|
|
||||||
|
@router.patch("/{user_id}/favorites", response_model=List[int])
|
||||||
|
def patch_user_favorites(
|
||||||
|
user_id: int,
|
||||||
|
favorites: List[int] = Body(..., embed=False, description="Array von Produkt-IDs"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
# Dein Frontend sendet bei PATCH das komplette Array → identisch zu PUT behandeln.
|
||||||
|
return replace_user_favorites(user_id, favorites, db, current)
|
96
apps/backend/app/core/auth.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
import secrets
|
||||||
|
from fastapi import Depends, HTTPException, Request, Response, status
|
||||||
|
from sqlalchemy.orm import Session as DBSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.models.session import Session as SessionModel
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
SESSION_COOKIE_NAME = "bacchus_session"
|
||||||
|
CSRF_COOKIE_NAME = "bacchus_csrf"
|
||||||
|
CSRF_HEADER_NAME = "X-CSRF-Token"
|
||||||
|
SESSION_TTL = timedelta(hours=8)
|
||||||
|
|
||||||
|
def _new_token() -> str:
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
# ---------- Role-Normalisierung ----------
|
||||||
|
def _normalize_role(role_raw) -> str:
|
||||||
|
# Enum? -> value
|
||||||
|
if hasattr(role_raw, "value"):
|
||||||
|
role_raw = role_raw.value
|
||||||
|
role = str(role_raw or "user").strip()
|
||||||
|
if "." in role: # z.B. "UserRole.admin"
|
||||||
|
role = role.split(".")[-1]
|
||||||
|
return role.lower()
|
||||||
|
|
||||||
|
def issue_csrf_cookie(resp: Response) -> str:
|
||||||
|
token = _new_token()
|
||||||
|
resp.set_cookie(
|
||||||
|
key=CSRF_COOKIE_NAME,
|
||||||
|
value=token,
|
||||||
|
max_age=7200,
|
||||||
|
secure=False, # PROD: True
|
||||||
|
samesite="lax",
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
return token
|
||||||
|
|
||||||
|
def clear_csrf_cookie(resp: Response) -> None:
|
||||||
|
resp.delete_cookie(key=CSRF_COOKIE_NAME, path="/", samesite="lax")
|
||||||
|
|
||||||
|
def verify_csrf(request: Request) -> None:
|
||||||
|
# CSRF nur für mutierende Methoden
|
||||||
|
if request.method in ("GET", "HEAD", "OPTIONS"):
|
||||||
|
return
|
||||||
|
cookie = request.cookies.get(CSRF_COOKIE_NAME)
|
||||||
|
header = request.headers.get(CSRF_HEADER_NAME)
|
||||||
|
if cookie != header:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="CSRF check failed")
|
||||||
|
|
||||||
|
def create_session(db: DBSession, user_id: int) -> str:
|
||||||
|
token = _new_token()
|
||||||
|
db.add(SessionModel(user_id=user_id, token=token))
|
||||||
|
db.commit()
|
||||||
|
return token
|
||||||
|
|
||||||
|
def set_session_cookie(resp: Response, token: str) -> None:
|
||||||
|
resp.set_cookie(
|
||||||
|
key=SESSION_COOKIE_NAME,
|
||||||
|
value=token,
|
||||||
|
httponly=True,
|
||||||
|
secure=False, # PROD: True
|
||||||
|
samesite="lax",
|
||||||
|
max_age=int(SESSION_TTL.total_seconds()),
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
def clear_session_cookie(resp: Response) -> None:
|
||||||
|
resp.delete_cookie(key=SESSION_COOKIE_NAME, path="/", samesite="lax")
|
||||||
|
|
||||||
|
def get_current_user(request: Request, db: DBSession = Depends(get_db)) -> User:
|
||||||
|
token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired session")
|
||||||
|
session = db.query(SessionModel).filter_by(token=token).first()
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired session")
|
||||||
|
return session.user
|
||||||
|
|
||||||
|
def requires_role(*roles: str):
|
||||||
|
roles_norm = tuple(_normalize_role(r) for r in roles)
|
||||||
|
def dep(user: User = Depends(get_current_user)):
|
||||||
|
user_role = _normalize_role(getattr(user, "role", None))
|
||||||
|
if roles_norm and user_role not in roles_norm:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role")
|
||||||
|
return user
|
||||||
|
return dep
|
||||||
|
|
||||||
|
# ---- Hybrid-Gates ----
|
||||||
|
def requires_role_relaxed(*roles: str):
|
||||||
|
return requires_role(*roles)
|
||||||
|
|
||||||
|
def requires_role_mgmt(*roles: str):
|
||||||
|
# Später hier optional Session-Typ "management" erzwingen
|
||||||
|
return requires_role(*roles)
|
32
apps/backend/app/core/database.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
# Datenbank-URL (später besser aus Umgebungsvariable laden)
|
||||||
|
DATABASE_URL = "postgresql://postgres:bacchus@localhost:5432/bacchus"
|
||||||
|
|
||||||
|
engine = create_engine(DATABASE_URL)
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
# Dependency für FastAPI-Routen
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# Optional: Kontextmanager für Skripte außerhalb von FastAPI
|
||||||
|
@contextmanager
|
||||||
|
def db_session():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
db.commit()
|
||||||
|
except:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
108
apps/backend/app/main.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import os
|
||||||
|
|
||||||
|
ENV_PATH = Path(__file__).resolve().parent.parent / ".env"
|
||||||
|
os.environ["DOTENV_PATH"] = str(ENV_PATH)
|
||||||
|
load_dotenv(dotenv_path=ENV_PATH, override=True)
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request, Response
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
|
from app.api import (
|
||||||
|
auth, users, products, bookings, deliveries, stats, topups,
|
||||||
|
audit_logs, profile, transactions, categories, ledger, admin_settings, paypal_ipn
|
||||||
|
)
|
||||||
|
from app.core.auth import CSRF_HEADER_NAME, verify_csrf
|
||||||
|
|
||||||
|
ALLOWED_ORIGINS = ["http://localhost:5173"]
|
||||||
|
|
||||||
|
# CSRF-Ausnahmen (nur für DEV sinnvoll!)
|
||||||
|
CSRF_EXEMPT = {
|
||||||
|
"/auth/pin-login",
|
||||||
|
"/auth/management-login",
|
||||||
|
"/auth/logout",
|
||||||
|
"/auth/csrf",
|
||||||
|
"/docs",
|
||||||
|
"/openapi.json",
|
||||||
|
"/redoc",
|
||||||
|
"/docs/oauth2-redirect",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Swagger-/Redoc-Pfade
|
||||||
|
DOCS_PATHS = ("/docs", "/redoc", "/openapi.json")
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=ALLOWED_ORIGINS,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||||
|
allow_headers=["Content-Type", CSRF_HEADER_NAME],
|
||||||
|
)
|
||||||
|
|
||||||
|
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
path = request.url.path
|
||||||
|
|
||||||
|
# CSRF nur für mutierende Methoden prüfen, ausgenommene Pfade überspringen
|
||||||
|
if (
|
||||||
|
request.method in {"POST", "PUT", "PATCH", "DELETE"}
|
||||||
|
and not any(path == p or path.startswith(p + "/") for p in CSRF_EXEMPT)
|
||||||
|
):
|
||||||
|
verify_csrf(request)
|
||||||
|
|
||||||
|
response: Response = await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
if any(path == p or path.startswith(p + "/") for p in DOCS_PATHS):
|
||||||
|
response.headers.setdefault(
|
||||||
|
"Content-Security-Policy",
|
||||||
|
"default-src 'self' https:; "
|
||||||
|
"img-src 'self' data: https:; "
|
||||||
|
"style-src 'self' 'unsafe-inline' https:; "
|
||||||
|
"script-src 'self' 'unsafe-inline' https:"
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Normale Security-Header + strikte CSP
|
||||||
|
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||||
|
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||||
|
response.headers.setdefault("Referrer-Policy", "no-referrer")
|
||||||
|
response.headers.setdefault(
|
||||||
|
"Content-Security-Policy",
|
||||||
|
"default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'"
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
app.add_middleware(SecurityHeadersMiddleware)
|
||||||
|
|
||||||
|
# Medienordner sicherstellen (verhindert RuntimeError beim Mount)
|
||||||
|
os.makedirs("media/avatars", exist_ok=True)
|
||||||
|
|
||||||
|
# Static files für Avatare & Medien
|
||||||
|
app.mount("/media", StaticFiles(directory="media"), name="media")
|
||||||
|
|
||||||
|
# Router registrieren
|
||||||
|
app.include_router(auth.router)
|
||||||
|
app.include_router(users.router)
|
||||||
|
app.include_router(products.router)
|
||||||
|
app.include_router(bookings.router)
|
||||||
|
app.include_router(deliveries.router)
|
||||||
|
app.include_router(stats.router)
|
||||||
|
app.include_router(topups.router)
|
||||||
|
app.include_router(audit_logs.router)
|
||||||
|
app.include_router(profile.router)
|
||||||
|
app.include_router(transactions.router)
|
||||||
|
app.include_router(categories.router)
|
||||||
|
app.include_router(ledger.router)
|
||||||
|
app.include_router(admin_settings.router)
|
||||||
|
app.include_router(paypal_ipn.router)
|
||||||
|
# app.include_router(sessions.router)
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def root():
|
||||||
|
return {"message": "Bacchus API läuft"}
|
29
apps/backend/app/models/audit_log.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from app.core.database import Base
|
||||||
|
from sqlalchemy import Column, Integer, String, DateTime, Enum, ForeignKey
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime
|
||||||
|
import enum
|
||||||
|
|
||||||
|
|
||||||
|
class AuditAction(enum.Enum):
|
||||||
|
booking_create = "booking_create"
|
||||||
|
booking_delete = "booking_delete"
|
||||||
|
topup_create = "topup_create"
|
||||||
|
# Weitere Aktionen können hier ergänzt werden, z. B.:
|
||||||
|
# topup_confirm = "topup_confirm"
|
||||||
|
# user_update = "user_update"
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(Base):
|
||||||
|
__tablename__ = "audit_logs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
action = Column(String, nullable=False)
|
||||||
|
amount_cents = Column(Integer, nullable=True)
|
||||||
|
old_balance_cents = Column(Integer, nullable=True)
|
||||||
|
new_balance_cents = Column(Integer, nullable=True)
|
||||||
|
info = Column(String, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
user = relationship("User")
|
18
apps/backend/app/models/booking.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from sqlalchemy import Column, Integer, ForeignKey, DateTime, String
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from app.core.database import Base
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class Booking(Base):
|
||||||
|
__tablename__ = "bookings"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||||
|
amount = Column(Integer, nullable=False) # Anzahl der Produkte in dieser Buchung
|
||||||
|
total_cents = Column(Integer, nullable=False) # Gesamtsumme in Cent (Preis * Anzahl)
|
||||||
|
comment = Column(String, nullable=True)
|
||||||
|
timestamp = Column(DateTime, default=datetime.utcnow, nullable=False) # Zeitpunkt der Buchung
|
||||||
|
|
||||||
|
user = relationship("User")
|
||||||
|
product = relationship("Product")
|
11
apps/backend/app/models/config.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from sqlalchemy import Column, String, Text, DateTime, func
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
class Config(Base):
|
||||||
|
__tablename__ = "config"
|
||||||
|
|
||||||
|
key = Column(String(64), primary_key=True, index=True)
|
||||||
|
value = Column(Text, nullable=False, default="")
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
22
apps/backend/app/models/delivery.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from app.core.database import Base
|
||||||
|
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class Delivery(Base):
|
||||||
|
__tablename__ = "deliveries"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
product_id = Column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
|
||||||
|
amount = Column(Integer, nullable=False, default=0) # gelieferte Menge (Stück / Einheiten)
|
||||||
|
price_cents = Column(Integer, nullable=False, default=0) # Einkaufspreis in Cent (gesamt oder pro Einheit – je nach Modell)
|
||||||
|
delivered_at = Column(DateTime(timezone=True), nullable=True) # Lieferdatum
|
||||||
|
supplier = Column(String, nullable=True) # Lieferant (frei)
|
||||||
|
invoice_number = Column(String, nullable=True) # Rechnungsnummer
|
||||||
|
created_by = Column(Integer, nullable=True) # optional: User-ID, der die Lieferung erfasst hat
|
||||||
|
deposit_return_cents = Column(Integer, nullable=False, server_default="0")
|
||||||
|
note = Column(Text, nullable=True) # oder String(1000)
|
||||||
|
|
||||||
|
# Rückverknüpfung zum Produkt
|
||||||
|
product = relationship("Product", back_populates="deliveries")
|
31
apps/backend/app/models/product.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Boolean, DateTime
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
class Product(Base):
|
||||||
|
__tablename__ = "products"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String, nullable=False, unique=True, index=True)
|
||||||
|
category = Column(String, nullable=True, index=True)
|
||||||
|
volume_ml = Column(Integer, nullable=False)
|
||||||
|
price_cents = Column(Integer, nullable=False)
|
||||||
|
supplier_number = Column(String, nullable=True)
|
||||||
|
image_url = Column(String, nullable=True)
|
||||||
|
|
||||||
|
stock = Column(Integer, nullable=False, default=0)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
pack_size = Column(Integer, nullable=False, default=1) # z.B. 6, 12, 24
|
||||||
|
purchase_price_cents = Column(Integer, nullable=False, default=0)
|
||||||
|
# >>> NEU: Gegenbeziehung zur Delivery-Relation
|
||||||
|
deliveries = relationship(
|
||||||
|
"Delivery",
|
||||||
|
back_populates="product",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
14
apps/backend/app/models/session.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from app.core.database import Base
|
||||||
|
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class Session(Base):
|
||||||
|
__tablename__ = "sessions"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
token = Column(String(64), unique=True, nullable=False, index=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
user = relationship("User")
|
25
apps/backend/app/models/topup.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from app.core.database import Base
|
||||||
|
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Enum
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime
|
||||||
|
import enum
|
||||||
|
|
||||||
|
class TopupStatus(enum.Enum):
|
||||||
|
pending = "pending"
|
||||||
|
confirmed = "confirmed"
|
||||||
|
rejected = "rejected"
|
||||||
|
|
||||||
|
class Topup(Base):
|
||||||
|
__tablename__ = "topups"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
amount_cents = Column(Integer, nullable=False)
|
||||||
|
status = Column(Enum(TopupStatus), default=TopupStatus.pending, nullable=False)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
confirmed_at = Column(DateTime, nullable=True)
|
||||||
|
paypal_email = Column(String, nullable=True)
|
||||||
|
note = Column(String, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
user = relationship("User")
|
42
apps/backend/app/models/user.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from app.core.database import Base
|
||||||
|
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Enum, JSON
|
||||||
|
import enum
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class UserRole(enum.Enum):
|
||||||
|
user = "user"
|
||||||
|
manager = "manager"
|
||||||
|
admin = "admin"
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
email = Column(String, nullable=False, unique=True, index=True)
|
||||||
|
hashed_password = Column(String, nullable=False)
|
||||||
|
# Hinweis: hashed_pin als unique kann problematisch sein, falls None – in deiner DB ist es gesetzt.
|
||||||
|
hashed_pin = Column(String, nullable=False, unique=True, index=True)
|
||||||
|
|
||||||
|
# Sichtbarkeit/Alias für die Stats-Seite
|
||||||
|
alias = Column(String, nullable=True, unique=True)
|
||||||
|
public_stats = Column(Boolean, nullable=False, default=False) # <— NEU: Opt-in
|
||||||
|
|
||||||
|
paypal_email = Column(String, nullable=True)
|
||||||
|
role = Column(Enum(UserRole), nullable=False, default=UserRole.user)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
balance_cents = Column(Integer, nullable=False, default=0)
|
||||||
|
favorites = Column(JSON, nullable=False, default=list)
|
||||||
|
avatar_url = Column(String, nullable=True)
|
||||||
|
|
||||||
|
# PIN-Sicherheit (Lockout etc.)
|
||||||
|
from sqlalchemy import String as SQLAString
|
||||||
|
pin_lookup = Column(SQLAString(64), index=True, nullable=True) # HMAC-SHA256(PEPPER, pin)
|
||||||
|
pin_fail_count = Column(Integer, nullable=False, default=0)
|
||||||
|
pin_locked_until = Column(DateTime, nullable=True)
|
29
apps/backend/app/schemas/audit_log.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# Pydantic v1/v2 Kompatibilität
|
||||||
|
try:
|
||||||
|
from pydantic import BaseModel, ConfigDict # v2
|
||||||
|
_V2 = True
|
||||||
|
except ImportError: # v1
|
||||||
|
from pydantic import BaseModel # type: ignore
|
||||||
|
_V2 = False
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLogOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
user_id: Optional[int] = None
|
||||||
|
timestamp: Optional[datetime] = None
|
||||||
|
# WICHTIG: frei als String, kein zu enges Enum -> verhindert ResponseValidationError
|
||||||
|
action: str
|
||||||
|
info: Optional[str] = None
|
||||||
|
old_balance_cents: Optional[int] = None
|
||||||
|
new_balance_cents: Optional[int] = None
|
||||||
|
|
||||||
|
if _V2:
|
||||||
|
# Pydantic v2
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
else:
|
||||||
|
# Pydantic v1
|
||||||
|
class Config:
|
||||||
|
orm_mode = True
|
19
apps/backend/app/schemas/booking.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class BookingBase(BaseModel):
|
||||||
|
user_id: int
|
||||||
|
product_id: int
|
||||||
|
amount: int
|
||||||
|
total_cents: int
|
||||||
|
comment: str | None = None
|
||||||
|
|
||||||
|
class BookingCreate(BookingBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class BookingOut(BookingBase):
|
||||||
|
id: int
|
||||||
|
timestamp: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True # Pydantic V2
|
38
apps/backend/app/schemas/delivery.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
# Bestehendes Einzeilen-Schema bleibt
|
||||||
|
class DeliveryBase(BaseModel):
|
||||||
|
product_id: int
|
||||||
|
amount: int
|
||||||
|
price_cents: int
|
||||||
|
delivered_at: Optional[date] = None
|
||||||
|
supplier: Optional[str] = None
|
||||||
|
invoice_number: Optional[str] = None
|
||||||
|
created_by: Optional[int] = None
|
||||||
|
note: str | None = None
|
||||||
|
deposit_return_cents: int = 0
|
||||||
|
model_config = ConfigDict(from_attributes=True) # statt orm_mode
|
||||||
|
|
||||||
|
class DeliveryCreate(DeliveryBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class DeliveryOut(DeliveryBase):
|
||||||
|
id: int
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
# 🆕 Für die neue Seite (Header + Items + Pfand)
|
||||||
|
class DeliveryItemIn(BaseModel):
|
||||||
|
product_id: int
|
||||||
|
quantity_units: int
|
||||||
|
unit_cost_cents: int
|
||||||
|
|
||||||
|
class DeliveryCreateBulk(BaseModel):
|
||||||
|
supplier: Optional[str] = None
|
||||||
|
date: Optional[date] = None
|
||||||
|
invoice_no: Optional[str] = None
|
||||||
|
note: Optional[str] = None
|
||||||
|
deposit_return_cents: int = 0 # Netto-Pfand (Cent, positiv)
|
||||||
|
items: List[DeliveryItemIn]
|
28
apps/backend/app/schemas/product.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class ProductBase(BaseModel):
|
||||||
|
name: str
|
||||||
|
price_cents: int # Verkaufspreis (falls genutzt)
|
||||||
|
purchase_price_cents: int = 0 # 🆕 EK je Einheit (Cent)
|
||||||
|
pack_size: int = 1 # 🆕 6, 12, 24 ...
|
||||||
|
supplier_number: Optional[str] = None
|
||||||
|
volume_ml: Optional[int] = None
|
||||||
|
category: Optional[str] = None
|
||||||
|
stock: int = 0 # Default sinnvoller
|
||||||
|
is_active: bool = True # Default sinnvoller
|
||||||
|
|
||||||
|
class ProductCreate(ProductBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ProductUpdate(ProductBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ProductOut(ProductBase):
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
32
apps/backend/app/schemas/topup.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
class TopupStatus(str, Enum):
|
||||||
|
pending = "pending"
|
||||||
|
confirmed = "confirmed"
|
||||||
|
rejected = "rejected"
|
||||||
|
|
||||||
|
# Für Ausgaben und Admin-Create weiterhin kompatibel:
|
||||||
|
class TopupBase(BaseModel):
|
||||||
|
user_id: Optional[int] = None # <- optional gemacht
|
||||||
|
amount_cents: int
|
||||||
|
paypal_email: Optional[str] = None
|
||||||
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
class TopupCreate(TopupBase):
|
||||||
|
# user_id kann für Admin-Aufrufe gesetzt werden, für User-Create weggelassen
|
||||||
|
pass
|
||||||
|
|
||||||
|
class TopupStatusUpdate(BaseModel):
|
||||||
|
status: TopupStatus
|
||||||
|
|
||||||
|
class TopupOut(TopupBase):
|
||||||
|
id: int
|
||||||
|
status: TopupStatus
|
||||||
|
created_at: datetime
|
||||||
|
confirmed_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True # pydantic v2 (ersetzt orm_mode)
|
46
apps/backend/app/schemas/user.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
from pydantic import BaseModel, EmailStr, constr
|
||||||
|
from typing import Optional, List
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
class UserRole(str, Enum):
|
||||||
|
user = "user"
|
||||||
|
manager = "manager"
|
||||||
|
admin = "admin"
|
||||||
|
|
||||||
|
class UserOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
email: EmailStr
|
||||||
|
alias: Optional[str] = None
|
||||||
|
paypal_email: Optional[EmailStr] = None
|
||||||
|
role: UserRole
|
||||||
|
is_active: bool
|
||||||
|
balance_cents: int
|
||||||
|
favorites: List[int]
|
||||||
|
# NEU:
|
||||||
|
avatar_url: Optional[str] = None
|
||||||
|
public_stats: bool
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
orm_mode = True
|
||||||
|
|
||||||
|
class UserCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
email: EmailStr
|
||||||
|
password: constr(min_length=8)
|
||||||
|
pin: constr(min_length=6, max_length=6)
|
||||||
|
alias: Optional[str] = None
|
||||||
|
paypal_email: Optional[EmailStr] = None
|
||||||
|
role: Optional[UserRole] = UserRole.user
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
email: Optional[EmailStr] = None
|
||||||
|
password: Optional[constr(min_length=8)] = None
|
||||||
|
pin: Optional[constr(min_length=6, max_length=6)] = None
|
||||||
|
alias: Optional[str] = None
|
||||||
|
paypal_email: Optional[EmailStr] = None
|
||||||
|
role: Optional[UserRole] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
balance_cents: Optional[int] = None
|
||||||
|
favorites: Optional[List[int]] = None
|
19
apps/backend/app/services/sessions.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import secrets
|
||||||
|
from sqlalchemy.orm import Session as DBSession
|
||||||
|
from app.models.session import Session as SessionModel
|
||||||
|
|
||||||
|
def create_session(db: DBSession, user_id: int) -> str:
|
||||||
|
token = secrets.token_hex(32)
|
||||||
|
session = SessionModel(user_id=user_id, token=token)
|
||||||
|
db.add(session)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(session)
|
||||||
|
return token
|
||||||
|
|
||||||
|
def get_user_by_token(db: DBSession, token: str):
|
||||||
|
session = db.query(SessionModel).filter_by(token=token).first()
|
||||||
|
return session.user if session else None
|
||||||
|
|
||||||
|
def delete_session(db: DBSession, token: str):
|
||||||
|
db.query(SessionModel).filter_by(token=token).delete()
|
||||||
|
db.commit()
|
BIN
apps/backend/media/avatars/1.gif
Normal file
After Width: | Height: | Size: 1.3 MiB |
BIN
apps/backend/media/avatars/1.jpg
Normal file
After Width: | Height: | Size: 205 KiB |
BIN
apps/backend/media/avatars/1.png
Normal file
After Width: | Height: | Size: 3.8 MiB |
BIN
apps/backend/media/avatars/1.webp
Normal file
After Width: | Height: | Size: 212 B |
BIN
apps/backend/media/avatars/3.png
Normal file
After Width: | Height: | Size: 2.6 MiB |
BIN
apps/backend/media/avatars/5.png
Normal file
After Width: | Height: | Size: 6.5 KiB |
BIN
apps/backend/media/avatars/7.png
Normal file
After Width: | Height: | Size: 21 KiB |
BIN
apps/backend/media/avatars/avatar-default.png
Normal file
After Width: | Height: | Size: 896 KiB |
@@ -1,63 +0,0 @@
|
|||||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
|
||||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
||||||
|
|
||||||
CREATE TABLE users (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
email TEXT NOT NULL UNIQUE,
|
|
||||||
display_name TEXT NOT NULL,
|
|
||||||
password_hash TEXT NOT NULL,
|
|
||||||
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user','admin')),
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE products (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
price_cents INT NOT NULL CHECK (price_cents >= 0),
|
|
||||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE ledger (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id),
|
|
||||||
amount_cents INT NOT NULL,
|
|
||||||
kind TEXT NOT NULL CHECK (kind IN ('topup','purchase','adjustment')),
|
|
||||||
ref_id UUID,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE orders (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id),
|
|
||||||
total_cents INT NOT NULL CHECK (total_cents >= 0),
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE order_items (
|
|
||||||
order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
|
||||||
product_id UUID NOT NULL REFERENCES products(id),
|
|
||||||
qty INT NOT NULL CHECK (qty > 0),
|
|
||||||
price_cents INT NOT NULL CHECK (price_cents >= 0),
|
|
||||||
PRIMARY KEY (order_id, product_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE inventory_movements (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
product_id UUID NOT NULL REFERENCES products(id),
|
|
||||||
qty INT NOT NULL,
|
|
||||||
reason TEXT NOT NULL CHECK (reason IN ('purchase','consumption','correction')),
|
|
||||||
note TEXT,
|
|
||||||
created_by UUID REFERENCES users(id),
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE VIEW user_balances AS
|
|
||||||
SELECT user_id, COALESCE(SUM(amount_cents),0) AS balance_cents
|
|
||||||
FROM ledger GROUP BY user_id;
|
|
||||||
|
|
||||||
CREATE VIEW product_stock AS
|
|
||||||
SELECT p.id AS product_id, COALESCE(SUM(m.qty),0) AS stock
|
|
||||||
FROM products p
|
|
||||||
LEFT JOIN inventory_movements m ON m.product_id = p.id
|
|
||||||
GROUP BY p.id;
|
|
1
apps/backend/requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
81
apps/backend/setup_admin.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Body
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.schemas.user import UserOut, UserCreate, UserUpdate, UserRole
|
||||||
|
from app.models.user import User
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/users", tags=["users"])
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# — Endpoints —
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[UserOut])
|
||||||
|
def list_users(db: Session = Depends(get_db)):
|
||||||
|
return db.query(User).all()
|
||||||
|
|
||||||
|
@router.get("/{user_id}", response_model=UserOut)
|
||||||
|
def get_user(user_id: int, db: Session = Depends(get_db)):
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
return user
|
||||||
|
|
||||||
|
@router.post("/", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_user(user: UserCreate, db: Session = Depends(get_db)):
|
||||||
|
if db.query(User).filter(User.email == user.email).first():
|
||||||
|
raise HTTPException(status_code=409, detail="E-Mail already exists")
|
||||||
|
if user.alias and db.query(User).filter(User.alias == user.alias).first():
|
||||||
|
raise HTTPException(status_code=409, detail="Alias already exists")
|
||||||
|
hashed_password = pwd_context.hash(user.password)
|
||||||
|
hashed_pin = pwd_context.hash(user.pin)
|
||||||
|
db_user = User(
|
||||||
|
name=user.name,
|
||||||
|
email=user.email,
|
||||||
|
hashed_password=hashed_password,
|
||||||
|
hashed_pin=hashed_pin,
|
||||||
|
alias=user.alias,
|
||||||
|
paypal_email=user.paypal_email,
|
||||||
|
role=user.role,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
db.add(db_user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_user)
|
||||||
|
return db_user
|
||||||
|
|
||||||
|
@router.patch("/{user_id}", response_model=UserOut)
|
||||||
|
def update_user(user_id: int, user: UserUpdate, db: Session = Depends(get_db)):
|
||||||
|
db_user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not db_user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
# ... (bestehende Update-Logik) ...
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_user)
|
||||||
|
return db_user
|
||||||
|
|
||||||
|
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_user(user_id: int, db: Session = Depends(get_db)):
|
||||||
|
db_user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not db_user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
db.delete(db_user)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# — NEU: PIN-Login endpoint —
|
||||||
|
@router.post("/login/pin", response_model=UserOut)
|
||||||
|
def login_with_pin(pin: str = Body(...), db: Session = Depends(get_db)):
|
||||||
|
# alle aktiven User laden und PIN gegen hashed_pin prüfen
|
||||||
|
users = db.query(User).filter(User.is_active == True).all()
|
||||||
|
for u in users:
|
||||||
|
if pwd_context.verify(pin, u.hashed_pin):
|
||||||
|
return u
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid PIN")
|
@@ -1,14 +0,0 @@
|
|||||||
use axum::{
|
|
||||||
routing::get,
|
|
||||||
Router,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() {
|
|
||||||
// build our application with a single route
|
|
||||||
let app = Router::new().route("/", get(|| async { "Hello, World!" }));
|
|
||||||
|
|
||||||
// run our app with hyper, listening globally on port 3000
|
|
||||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
|
|
||||||
axum::serve(listener, app).await.unwrap();
|
|
||||||
}
|
|
@@ -1 +0,0 @@
|
|||||||
VITE_API_URL=http://localhost:8080
|
|
2168
apps/frontend/package-lock.json
generated
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bacchus-ui",
|
"name": "bacchus-frontend",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -8,17 +8,17 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.1.1",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^18.2.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-router-dom": "^7.8.2",
|
"react-router-dom": "^6.23.0",
|
||||||
"recharts": "^3.1.2"
|
"recharts": "^2.15.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^5.0",
|
"@vitejs/plugin-react": "^4.0.0",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^4.1.12",
|
"tailwindcss": "^3.4.3",
|
||||||
"vite": "^7.0.4"
|
"vite": "^7.0.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,9 +1,8 @@
|
|||||||
|
// api.js
|
||||||
export const API_BASE = import.meta.env.VITE_API_BASE || "http://localhost:8000";
|
export const API_BASE = import.meta.env.VITE_API_BASE || "http://localhost:8000";
|
||||||
|
|
||||||
/* ===================== Cookies & CSRF ===================== */
|
/* ===================== Cookies & CSRF ===================== */
|
||||||
|
|
||||||
const API = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000";
|
|
||||||
|
|
||||||
function getCookie(name) {
|
function getCookie(name) {
|
||||||
const re = new RegExp(
|
const re = new RegExp(
|
||||||
"(?:^|; )" +
|
"(?:^|; )" +
|
||||||
@@ -14,7 +13,6 @@ function getCookie(name) {
|
|||||||
return m ? decodeURIComponent(m[1]) : null;
|
return m ? decodeURIComponent(m[1]) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Holt CSRF
|
|
||||||
async function getCsrfMaybe() {
|
async function getCsrfMaybe() {
|
||||||
const fromCookie = getCookie("bacchus_csrf");
|
const fromCookie = getCookie("bacchus_csrf");
|
||||||
if (fromCookie) return fromCookie;
|
if (fromCookie) return fromCookie;
|
||||||
@@ -29,7 +27,6 @@ async function getCsrfMaybe() {
|
|||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
|
||||||
export async function getCsrfToken() {
|
export async function getCsrfToken() {
|
||||||
return getCsrfMaybe();
|
return getCsrfMaybe();
|
||||||
}
|
}
|
||||||
@@ -42,11 +39,9 @@ export async function request(path, options = {}) {
|
|||||||
|
|
||||||
const headers = {
|
const headers = {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
...(options.body !== undefined ? { "Content-Type": "application/json" } : {}),
|
...(options.body !== undefined && !(options.body instanceof FormData) ? { "Content-Type": "application/json" } : {}),
|
||||||
...(options.headers || {}),
|
...(options.headers || {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
headers["X-CSRF-Token"] = await getCsrfMaybe();
|
headers["X-CSRF-Token"] = await getCsrfMaybe();
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
@@ -61,11 +56,7 @@ export async function request(path, options = {}) {
|
|||||||
const text = await res.text().catch(() => "");
|
const text = await res.text().catch(() => "");
|
||||||
let data = null;
|
let data = null;
|
||||||
if (text) {
|
if (text) {
|
||||||
try {
|
try { data = JSON.parse(text); } catch { /* non-JSON */ }
|
||||||
data = JSON.parse(text);
|
|
||||||
} catch {
|
|
||||||
/* Hi */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -79,17 +70,17 @@ export async function request(path, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getJson(path, params) {
|
export async function getJson(path, params) {
|
||||||
const qs =
|
let qs = "";
|
||||||
params && typeof params === "object"
|
if (params && typeof params === "object") {
|
||||||
? "?" +
|
const pairs = Object.entries(params)
|
||||||
Object.entries(params)
|
.filter(([, v]) => v !== undefined && v !== null && v !== "")
|
||||||
.filter(([, v]) => v !== undefined && v !== null)
|
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
|
||||||
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
|
if (pairs.length) qs = "?" + pairs.join("&"); // <— nur wenn wirklich Paare existieren
|
||||||
.join("&")
|
}
|
||||||
: "";
|
|
||||||
return request(`${path}${qs}`, { method: "GET" });
|
return request(`${path}${qs}`, { method: "GET" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ===================== Auth / Session ===================== */
|
/* ===================== Auth / Session ===================== */
|
||||||
|
|
||||||
export function loginPin(pin) {
|
export function loginPin(pin) {
|
||||||
@@ -125,11 +116,10 @@ export function getStatsSummary() {
|
|||||||
return request("/stats/summary");
|
return request("/stats/summary");
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===================== Users & Favorites ===================== */
|
/* ===================== Users ===================== */
|
||||||
|
|
||||||
// Parametrische Liste (Suche/Filter/Sort/Paging)
|
|
||||||
export function listUsers(params = {}) {
|
export function listUsers(params = {}) {
|
||||||
return getJson("/users/", params); // trailing slash beibehalten
|
return getJson("/users/", params);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUsers() {
|
export function getUsers() {
|
||||||
@@ -158,7 +148,6 @@ export function deleteUser(userId) {
|
|||||||
return request(`/users/${userId}`, { method: "DELETE" });
|
return request(`/users/${userId}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sicherheitsaktionen (Admin/Manager)
|
|
||||||
export function setUserPin(userId, pin) {
|
export function setUserPin(userId, pin) {
|
||||||
return request(`/users/${userId}/set-pin`, {
|
return request(`/users/${userId}/set-pin`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -200,6 +189,26 @@ export function replaceFavorites(userId, favorites) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// für logs/admintransactionen
|
||||||
|
function pickName(u) {
|
||||||
|
const full = [u.first_name, u.last_name].filter(Boolean).join(" ").trim();
|
||||||
|
return u.display_name || u.name || (full || null) || u.username || u.email || `User ${u.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEU: kein Versuch mehr, /users/lite aufzurufen → keine 422 mehr in der Konsole
|
||||||
|
export async function getUsersLite({ limit = 200, offset = 0 } = {}) {
|
||||||
|
const data = await getJson("/users/", { limit, offset }); // nur dieser Endpoint
|
||||||
|
const arr = Array.isArray(data?.items) ? data.items : Array.isArray(data) ? data : [];
|
||||||
|
return arr.map(u => ({ id: u.id, name: pickName(u) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function getUsersMap(opts) {
|
||||||
|
const list = await getUsersLite(opts);
|
||||||
|
const map = {};
|
||||||
|
for (const u of list) map[u.id] = u.name;
|
||||||
|
return map;
|
||||||
|
}
|
||||||
/* ===================== Profile / Avatar ===================== */
|
/* ===================== Profile / Avatar ===================== */
|
||||||
|
|
||||||
export async function uploadAvatar(file) {
|
export async function uploadAvatar(file) {
|
||||||
@@ -210,7 +219,7 @@ export async function uploadAvatar(file) {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: form,
|
body: form,
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: { "X-CSRF-Token": await getCsrfMaybe() }, // multipart: kein Content-Type setzen
|
headers: { "X-CSRF-Token": await getCsrfToken() },
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`Upload fehlgeschlagen (${res.status})`);
|
if (!res.ok) throw new Error(`Upload fehlgeschlagen (${res.status})`);
|
||||||
return res.json();
|
return res.json();
|
||||||
@@ -276,8 +285,7 @@ export function listBookings({ user_id, limit = 10, offset = 0 } = {}) {
|
|||||||
return getJson("/bookings/", { user_id, limit, offset });
|
return getJson("/bookings/", { user_id, limit, offset });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===================== Deliveries ===================== */
|
||||||
/* ===================== Deliveries (Manager/Admin) ===================== */
|
|
||||||
|
|
||||||
export function getDeliveries() {
|
export function getDeliveries() {
|
||||||
return request("/deliveries/");
|
return request("/deliveries/");
|
||||||
@@ -292,6 +300,56 @@ export function deleteDelivery(id) {
|
|||||||
return request(`/deliveries/${id}`, { method: "DELETE" });
|
return request(`/deliveries/${id}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- utils (numeric) ---
|
||||||
|
function toInt(x) {
|
||||||
|
return x == null || x === "" ? 0 : parseInt(x, 10) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDeliveryBulk(payload, products = []) {
|
||||||
|
// payload: { supplier, date, invoice_no, note, deposit_return_cents, items:[{product_id, quantity_units, unit_cost_cents}] }
|
||||||
|
|
||||||
|
const withUnits = {
|
||||||
|
...payload, // ← richtig, nicht ".payload"
|
||||||
|
items: (payload.items || []).map(it => {
|
||||||
|
const ps = (products.find(p => p.id === it.product_id)?.pack_size) ?? 1;
|
||||||
|
return {
|
||||||
|
...it, // ← richtig, nicht ".it"
|
||||||
|
quantity_units: toInt(it.quantity_units),
|
||||||
|
unit_cost_cents: toInt(it.unit_cost_cents),
|
||||||
|
units: toInt(it.quantity_units) * ps,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1) Versuche echten Bulk-Endpoint
|
||||||
|
try {
|
||||||
|
return await request("/deliveries/bulk", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(withUnits),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if ([404, 405, 501].includes(e?.status)) {
|
||||||
|
throw new Error("Server unterstützt /deliveries/bulk nicht – bitte Backend aktualisieren.");
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// PDF-Import → Draft
|
||||||
|
export async function importDeliveryInvoice(file) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
const res = await fetch(`${API_BASE}/deliveries/invoice/import`, {
|
||||||
|
method: "POST",
|
||||||
|
body: form,
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "X-CSRF-Token": await getCsrfToken() }, // multipart: KEIN Content-Type setzen
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
/* ===================== Topups (Manager/Admin) ===================== */
|
/* ===================== Topups (Manager/Admin) ===================== */
|
||||||
|
|
||||||
export function patchTopupStatus(topupId, status) {
|
export function patchTopupStatus(topupId, status) {
|
||||||
@@ -305,6 +363,27 @@ export function getMyTopups({ limit = 100, offset = 0 } = {}) {
|
|||||||
return getJson("/topups/me", { limit, offset });
|
return getJson("/topups/me", { limit, offset });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listTopupsAdmin({ limit = 200, offset = 0, status_filter, user_id } = {}) {
|
||||||
|
const params = { limit, offset };
|
||||||
|
if (status_filter) params.status_filter = status_filter;
|
||||||
|
if (user_id) params.user_id = user_id;
|
||||||
|
return getJson("/topups", params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTopupAdmin({ user_id, amount_cents, note = "" }) {
|
||||||
|
return request("/topups", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ user_id, amount_cents, note }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTopupStatus(id, status) {
|
||||||
|
return request(`/topups/${id}/status`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* ===================== Stats ===================== */
|
/* ===================== Stats ===================== */
|
||||||
|
|
||||||
export function getConsumptionPerUser() {
|
export function getConsumptionPerUser() {
|
||||||
@@ -319,12 +398,22 @@ export function getMonthlyRanking({ year, month, limit = 10 } = {}) {
|
|||||||
return getJson("/stats/monthly-ranking", { year, month, limit });
|
return getJson("/stats/monthly-ranking", { year, month, limit });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getStatsMeta = () => request("/stats/meta");
|
||||||
|
|
||||||
|
export function getTopDrinkers({ period = "last_delivery", category = "all", limit = 5, tz = "Europe/Berlin" } = {}) {
|
||||||
|
return getJson("/stats/top-drinkers", { period, category, limit, tz });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProductShare({ period = "last_delivery", tz = "Europe/Berlin" } = {}) {
|
||||||
|
return getJson("/stats/product-share", { period, tz });
|
||||||
|
}
|
||||||
|
|
||||||
/* ===================== Audit / Transactions ===================== */
|
/* ===================== Audit / Transactions ===================== */
|
||||||
|
|
||||||
// Audit-Logs (Admin)
|
// Audit-Logs (Admin)
|
||||||
export function getAuditLogs({ limit = 100, offset = 0, user_id, action, q, date_from, date_to } = {}) {
|
export function getAuditLogs({ limit = 100, offset = 0, user_id, action, q, date_from, date_to } = {}) {
|
||||||
return getJson("/audit-logs/", { limit, offset, user_id, action, q, date_from, date_to });
|
return getJson("/audit-logs/", { limit, offset, user_id, action, q, date_from, date_to });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Eigene Transaktionen
|
// Eigene Transaktionen
|
||||||
export function getMyTransactions({ limit = 100, offset = 0 } = {}) {
|
export function getMyTransactions({ limit = 100, offset = 0 } = {}) {
|
||||||
@@ -332,26 +421,22 @@ export function getMyTransactions({ limit = 100, offset = 0 } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Admin: alle Transaktionen
|
// Admin: alle Transaktionen
|
||||||
export function getTransactionsAdmin({ limit = 100, offset = 0 } = {}) {
|
export function getTransactionsAdmin({ limit = 100, offset = 0, user_id, type, date_from, date_to } = {}) {
|
||||||
return getJson("/transactions", { limit, offset });
|
return getJson("/transactions", { limit, offset, user_id, type, date_from, date_to });
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===================== Categories (Manager/Admin) ===================== */
|
/* ===================== Categories (Manager/Admin) ===================== */
|
||||||
|
|
||||||
// Liste aller Kategorien (Array<string>)
|
|
||||||
export function getCategories() {
|
export function getCategories() {
|
||||||
return request("/categories/"); // trailing slash wie bei /products/
|
return request("/categories/");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kategorie umbenennen: alle Produkte mit old_name -> new_name
|
|
||||||
export function renameCategory(oldName, newName) {
|
export function renameCategory(oldName, newName) {
|
||||||
if (!oldName || !newName) throw new Error("oldName und newName sind erforderlich");
|
if (!oldName || !newName) throw new Error("oldName und newName sind erforderlich");
|
||||||
const qs = `?old_name=${encodeURIComponent(oldName)}&new_name=${encodeURIComponent(newName)}`;
|
const qs = `?old_name=${encodeURIComponent(oldName)}&new_name=${encodeURIComponent(newName)}`;
|
||||||
return request(`/categories/rename${qs}`, { method: "PUT" });
|
return request(`/categories/rename${qs}`, { method: "PUT" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kategorie löschen, optional umhängen (reassign_to)
|
|
||||||
// Wenn reassignTo null/undefined ist, wird category auf NULL gesetzt
|
|
||||||
export function deleteCategory(name, reassignTo = null) {
|
export function deleteCategory(name, reassignTo = null) {
|
||||||
if (!name) throw new Error("name ist erforderlich");
|
if (!name) throw new Error("name ist erforderlich");
|
||||||
const params = new URLSearchParams({ name });
|
const params = new URLSearchParams({ name });
|
||||||
@@ -359,7 +444,7 @@ export function deleteCategory(name, reassignTo = null) {
|
|||||||
return request(`/categories/?${params.toString()}`, { method: "DELETE" });
|
return request(`/categories/?${params.toString()}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================== Transaktionen Tracker ==============*/
|
/* ===================== Ledger / Topups (Self) ===================== */
|
||||||
|
|
||||||
export function getLedgerMe({ limit = 100, offset = 0, types = "topup,booking" } = {}) {
|
export function getLedgerMe({ limit = 100, offset = 0, types = "topup,booking" } = {}) {
|
||||||
return getJson("/ledger/me", { limit, offset, types });
|
return getJson("/ledger/me", { limit, offset, types });
|
||||||
@@ -374,84 +459,21 @@ export function createTopup(amount_cents, note = null) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===================== NEU: Stats (öffentlich, aber auth-pflichtig) =====================
|
export function addLedgerEntry(amount_cents, note = "") {
|
||||||
export const getStatsMeta = () => request("/stats/meta");
|
return request("/ledger", {
|
||||||
|
|
||||||
export function getTopDrinkers({ period = "last_delivery", category = "all", limit = 5, tz = "Europe/Berlin" } = {}) {
|
|
||||||
return getJson("/stats/top-drinkers", { period, category, limit, tz });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getProductShare({ period = "last_delivery", tz = "Europe/Berlin" } = {}) {
|
|
||||||
return getJson("/stats/product-share", { period, tz });
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
let CSRF = { token: null, header: "X-CSRF-Token", fetchedAt: 0 };
|
|
||||||
|
|
||||||
async function ensureCsrf() {
|
|
||||||
if (CSRF.token && Date.now() - CSRF.fetchedAt < 5 * 60 * 1000) return CSRF;
|
|
||||||
const res = await fetch(API + "/auth/csrf", { credentials: "include" });
|
|
||||||
let data = {};
|
|
||||||
try { data = await res.json(); } catch {}
|
|
||||||
CSRF.token =
|
|
||||||
data?.token || data?.csrf || data?.csrf_token || data?.value || null;
|
|
||||||
CSRF.header =
|
|
||||||
data?.header_name || data?.header || CSRF.header; // Server kann den Headernamen mitliefern
|
|
||||||
CSRF.fetchedAt = Date.now();
|
|
||||||
if (!CSRF.token) throw new Error("CSRF token missing from /auth/csrf");
|
|
||||||
return CSRF;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function req(path, opts = {}) {
|
|
||||||
const method = (opts.method || "GET").toUpperCase();
|
|
||||||
const isMutating = method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
|
|
||||||
|
|
||||||
const headers = { "Content-Type": "application/json", ...(opts.headers || {}) };
|
|
||||||
|
|
||||||
if (isMutating) {
|
|
||||||
const { token, header } = await ensureCsrf(); // <- WICHTIG
|
|
||||||
headers[header] = token;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(API + path, {
|
|
||||||
credentials: "include",
|
|
||||||
headers,
|
|
||||||
...opts,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text();
|
|
||||||
throw new Error(text || res.statusText);
|
|
||||||
}
|
|
||||||
return res.status === 204 ? null : res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listTopupsAdmin({ limit = 200, offset = 0, status_filter, user_id } = {}) {
|
|
||||||
const qs = new URLSearchParams({ limit, offset });
|
|
||||||
if (status_filter) qs.set("status_filter", status_filter);
|
|
||||||
if (user_id) qs.set("user_id", user_id);
|
|
||||||
return req(`/topups?${qs.toString()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export async function createTopupAdmin({ user_id, amount_cents, note = "" }) {
|
|
||||||
return req(`/topups`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ user_id, amount_cents, note }),
|
body: JSON.stringify({ amount_cents, note }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateTopupStatus(id, status) {
|
/* ===================== Admin Settings (PayPal) ===================== */
|
||||||
return req(`/topups/${id}/status`, {
|
export function getPaypalSettings() {
|
||||||
method: "PATCH",
|
return request("/admin/settings/paypal");
|
||||||
body: JSON.stringify({ status }),
|
}
|
||||||
|
|
||||||
|
export function updatePaypalSettings({ paypal_me = "", paypal_receiver = "" } = {}) {
|
||||||
|
return request("/admin/settings/paypal", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ paypal_me, paypal_receiver }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function getUsersLite({ limit = 200, offset = 0 } = {}) {
|
|
||||||
const lim = Math.min(Number(limit) || 200, 200);
|
|
||||||
return req(`/users?limit=${lim}&offset=${offset}`);
|
|
||||||
}
|
|
||||||
|
@@ -19,6 +19,18 @@ function applyOrderColorsFromStorage() {
|
|||||||
document.documentElement.style.setProperty("--order-bg2", c2);
|
document.documentElement.style.setProperty("--order-bg2", c2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- NEU: lokale Bestände anhand des Warenkorbs reduzieren
|
||||||
|
function decrementStockLocal(prevProducts, cart) {
|
||||||
|
const delta = {};
|
||||||
|
for (const { product, quantity } of Object.values(cart)) {
|
||||||
|
delta[product.id] = (delta[product.id] || 0) + quantity;
|
||||||
|
}
|
||||||
|
return prevProducts.map((p) =>
|
||||||
|
delta[p.id] ? { ...p, stock: (Number(p.stock) || 0) - delta[p.id] } : p
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
@@ -53,6 +65,11 @@ function chipStyleFor(cat, active) {
|
|||||||
return active ? strong(h) : soft(h);
|
return active ? strong(h) : soft(h);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ringColorForCategory(cat) {
|
||||||
|
if (!cat) return "#22c55e";
|
||||||
|
// nutzt deine bestehende Farblogik der Chips:
|
||||||
|
return chipStyleFor(cat, true).br; // starke Border-Farbe der Kategorie
|
||||||
|
}
|
||||||
// robustes Aktiv-Flag (API kann bool/zahl/string liefern)
|
// robustes Aktiv-Flag (API kann bool/zahl/string liefern)
|
||||||
function isActiveProduct(p) {
|
function isActiveProduct(p) {
|
||||||
const v = p?.is_active;
|
const v = p?.is_active;
|
||||||
@@ -236,11 +253,15 @@ const avatarSrc = useMemo(() => {
|
|||||||
product.price_cents * quantity
|
product.price_cents * quantity
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
setProducts((prev) => decrementStockLocal(prev, cart));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const refreshed = await getCurrentUser();
|
const refreshed = await getCurrentUser();
|
||||||
if (refreshed) setUser(refreshed);
|
if (refreshed) setUser(refreshed);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
setCart({});
|
setCart({});
|
||||||
|
|
||||||
try { await logout(); } finally { navigate("/", { replace: true }); }
|
try { await logout(); } finally { navigate("/", { replace: true }); }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e?.message || "Bezahlen fehlgeschlagen.");
|
setError(e?.message || "Bezahlen fehlgeschlagen.");
|
||||||
@@ -318,6 +339,7 @@ const avatarSrc = useMemo(() => {
|
|||||||
isFavorite={favorites.has(p.id)}
|
isFavorite={favorites.has(p.id)}
|
||||||
onToggleFavorite={handleToggleFavorite}
|
onToggleFavorite={handleToggleFavorite}
|
||||||
count={cart[p.id]?.quantity || 0}
|
count={cart[p.id]?.quantity || 0}
|
||||||
|
ringColor={ringColorForCategory(p.category)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
@@ -6,7 +6,8 @@ export default function ProductCard({
|
|||||||
onSelect,
|
onSelect,
|
||||||
isFavorite,
|
isFavorite,
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
count = 0, // Menge im Warenkorb
|
count = 0,
|
||||||
|
ringColor = "#22c55e", // Fallback
|
||||||
}) {
|
}) {
|
||||||
const baseName = (product?.name || '').replace(/\s+/g, '').toLowerCase();
|
const baseName = (product?.name || '').replace(/\s+/g, '').toLowerCase();
|
||||||
|
|
||||||
@@ -65,9 +66,12 @@ export default function ProductCard({
|
|||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className="relative rounded-xl overflow-hidden bg-gray-800 cursor-pointer transform transition duration-150 hover:scale-105 ring-2 ring-transparent hover:ring-green-500"
|
style={{ "--tw-ring-color": ringColor }}
|
||||||
|
className="relative rounded-xl overflow-hidden bg-gray-800 cursor-pointer transform transition duration-150 hover:scale-95
|
||||||
|
ring-0 hover:ring-4 ring-offset-0 ring-offset-gray-900
|
||||||
|
focus-visible:outline-none focus-visible:ring-2"
|
||||||
onClick={() => onSelect(product)}
|
onClick={() => onSelect(product)}
|
||||||
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && onSelect(product)}
|
onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && onSelect(product)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={imgSrc}
|
src={imgSrc}
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import './styles.css';
|
import './index.css';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
@@ -1,11 +1,20 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { listTopupsAdmin, patchTopupStatus, getUsersLite } from "../../api";
|
import { listTopupsAdmin, patchTopupStatus, getUsersLite, getPaypalSettings, updatePaypalSettings, createTopupAdmin } from "../../api";
|
||||||
|
|
||||||
const euro = (c) => (c ?? 0) / 100;
|
const euro = (c) => (c ?? 0) / 100;
|
||||||
const fmt = new Intl.NumberFormat("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
const fmt = new Intl.NumberFormat("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
const fmtDT = new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeStyle: "medium", timeZone: "Europe/Berlin" });
|
const fmtDT = new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeStyle: "medium", timeZone: "Europe/Berlin" });
|
||||||
const SHOW_CREATE = false;
|
const SHOW_CREATE = false;
|
||||||
|
|
||||||
|
const toText = (v) => {
|
||||||
|
if (v == null) return "—";
|
||||||
|
if (typeof v === "object") {
|
||||||
|
try { return JSON.stringify(v); } catch { return "[obj]"; }
|
||||||
|
}
|
||||||
|
return String(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
const STATUS_LABELS_DE = {
|
const STATUS_LABELS_DE = {
|
||||||
pending: "ausstehend",
|
pending: "ausstehend",
|
||||||
confirmed: "bestätigt",
|
confirmed: "bestätigt",
|
||||||
@@ -27,7 +36,6 @@ function Pill({ status }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/** Kleiner Avatar (Initialen) */
|
/** Kleiner Avatar (Initialen) */
|
||||||
function Initials({ label }) {
|
function Initials({ label }) {
|
||||||
const txt = String(label || "")
|
const txt = String(label || "")
|
||||||
@@ -211,6 +219,26 @@ function StatusSelect({ value, onChange }) {
|
|||||||
|
|
||||||
|
|
||||||
export default function AdminTransactionsPage() {
|
export default function AdminTransactionsPage() {
|
||||||
|
// PayPal-Settings (jetzt legal innerhalb der Component)
|
||||||
|
const [ppOpen, setPpOpen] = useState(false);
|
||||||
|
const [ppMe, setPpMe] = useState("");
|
||||||
|
const [ppReceiver, setPpReceiver] = useState("");
|
||||||
|
const [ppSaving, setPpSaving] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const cfg = await getPaypalSettings();
|
||||||
|
setPpMe(cfg?.paypal_me || "");
|
||||||
|
setPpReceiver(cfg?.paypal_receiver || "");
|
||||||
|
} catch {}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
async function savePaypal() {
|
||||||
|
setPpSaving(true);
|
||||||
|
try { await updatePaypalSettings({ paypal_me: ppMe, paypal_receiver: ppReceiver }); }
|
||||||
|
finally { setPpSaving(false); }
|
||||||
|
}
|
||||||
|
|
||||||
// Daten
|
// Daten
|
||||||
const [rows, setRows] = useState([]);
|
const [rows, setRows] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -351,6 +379,40 @@ const [statusFilter, setStatusFilter] = useState(""); // "", "pending", "confirm
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* PayPal-Einstellungen */}
|
||||||
|
<div className="rounded-2xl bg-white/5 border border-cyan-400/20 p-5">
|
||||||
|
<button type="button"
|
||||||
|
onClick={() => setPpOpen(o=>!o)}
|
||||||
|
className="w-full flex items-center justify-between text-left">
|
||||||
|
<h2 className="text-white/90 font-semibold">PayPal-Einstellungen</h2>
|
||||||
|
<span className="text-white/60">{ppOpen ? "▴" : "▾"}</span>
|
||||||
|
</button>
|
||||||
|
{ppOpen && (
|
||||||
|
<div className="mt-4 grid gap-3 md:grid-cols-3">
|
||||||
|
<label className="block">
|
||||||
|
<div className="text-xs text-white/60 mb-1">PayPal.me Handle (optional)</div>
|
||||||
|
<input value={ppMe} onChange={e=>setPpMe(e.target.value)}
|
||||||
|
className="w-full bg-black/40 text-white rounded-xl border border-white/20 px-3 py-2"
|
||||||
|
placeholder="z. B. Getraenkewart" />
|
||||||
|
</label>
|
||||||
|
<label className="block md:col-span-2">
|
||||||
|
<div className="text-xs text-white/60 mb-1">Geschäfts-E-Mail für Webscr/IPN</div>
|
||||||
|
<input value={ppReceiver} onChange={e=>setPpReceiver(e.target.value)}
|
||||||
|
className="w-full bg-black/40 text-white rounded-xl border border-white/20 px-3 py-2"
|
||||||
|
placeholder="kasse@example.org" />
|
||||||
|
</label>
|
||||||
|
<div className="md:col-span-3">
|
||||||
|
<button onClick={savePaypal} disabled={ppSaving}
|
||||||
|
className="px-4 py-2 rounded-xl font-semibold bg-emerald-600/80 hover:bg-emerald-600 text-white shadow disabled:opacity-60">
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* Tabelle */}
|
{/* Tabelle */}
|
||||||
<div className="rounded-2xl bg-white/5 border border-cyan-400/20 p-5">
|
<div className="rounded-2xl bg-white/5 border border-cyan-400/20 p-5">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
@@ -402,7 +464,7 @@ const [statusFilter, setStatusFilter] = useState(""); // "", "pending", "confirm
|
|||||||
{rows.map(r => {
|
{rows.map(r => {
|
||||||
const u = usersById.get(r.user_id);
|
const u = usersById.get(r.user_id);
|
||||||
const canAct = String(r.status) === "pending";
|
const canAct = String(r.status) === "pending";
|
||||||
const ts = r.created_at ? new Date(r.created_at) : null;
|
const ts = r.created_at ? new Date(String(r.created_at).replace(" ", "T")) : null;
|
||||||
return (
|
return (
|
||||||
<tr key={r.id} className="border-t border-white/10">
|
<tr key={r.id} className="border-t border-white/10">
|
||||||
<td className="py-2 pr-4 font-mono">{r.id}</td>
|
<td className="py-2 pr-4 font-mono">{r.id}</td>
|
||||||
@@ -410,7 +472,7 @@ const [statusFilter, setStatusFilter] = useState(""); // "", "pending", "confirm
|
|||||||
<td className="py-2 pr-4">{u?.name || u?.alias || u?.email || r.user_id}</td>
|
<td className="py-2 pr-4">{u?.name || u?.alias || u?.email || r.user_id}</td>
|
||||||
<td className="py-2 pr-4 font-mono">{fmt.format(euro(r.amount_cents))}</td>
|
<td className="py-2 pr-4 font-mono">{fmt.format(euro(r.amount_cents))}</td>
|
||||||
<td className="py-2 pr-4"><Pill status={String(r.status)} /></td>
|
<td className="py-2 pr-4"><Pill status={String(r.status)} /></td>
|
||||||
<td className="py-2 pr-4">{r.note || "—"}</td>
|
<td className="py-2 pr-4 font-mono break-all">{toText(r.note)}</td>
|
||||||
<td className="py-2 pr-4">
|
<td className="py-2 pr-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { getCapabilities, getStatsSummary } from "../../api";
|
import { getCapabilities, getStatsSummary } from "../../api";
|
||||||
import { FiCreditCard, FiTrendingUp, FiDatabase, FiZap } from "react-icons/fi";
|
import { FiCreditCard, FiTrendingUp, FiDatabase, FiZap, FiAlertTriangle } from "react-icons/fi";
|
||||||
|
|
||||||
function Panel({ title, icon: Icon, children, accent = "cyan" }) {
|
function Panel({ title, icon: Icon, children, accent = "cyan" }) {
|
||||||
const accentClasses =
|
const accentClasses =
|
||||||
@@ -193,6 +193,23 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
|
||||||
|
{/* Leer */}
|
||||||
|
<Panel title="Leer :(" icon={FiAlertTriangle}>
|
||||||
|
<div className="text-white/70 text-sm mb-3">
|
||||||
|
Hier leere Produkte auflisten
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<a
|
||||||
|
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl
|
||||||
|
bg-emerald-500/20 text-emerald-200 border border-emerald-400/30
|
||||||
|
hover:bg-emerald-500/30 transition"
|
||||||
|
>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -2,19 +2,63 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { getAuditLogs, getUsersLite } from "../../api";
|
import { getAuditLogs, getUsersLite } from "../../api";
|
||||||
|
|
||||||
/* ----------------------------- kleine Utils ----------------------------- */
|
/* ----------------------------- kleine Utils ----------------------------- */
|
||||||
|
function classNames(...xs) { return xs.filter(Boolean).join(" "); }
|
||||||
|
|
||||||
function euroDelta(newC, oldC) {
|
function euroDelta(newC, oldC) {
|
||||||
if (newC == null || oldC == null) return null;
|
// null/undefined → kein Wert
|
||||||
const d = (newC - oldC) / 100;
|
if (newC == null || oldC == null) return "—";
|
||||||
|
// Strings erlauben
|
||||||
|
const n = Number(newC);
|
||||||
|
const o = Number(oldC);
|
||||||
|
if (!Number.isFinite(n) || !Number.isFinite(o)) return "—";
|
||||||
|
const d = (n - o) / 100;
|
||||||
return (d >= 0 ? "+" : "") + d.toFixed(2) + " €";
|
return (d >= 0 ? "+" : "") + d.toFixed(2) + " €";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function fmtDT(v) {
|
function fmtDT(v) {
|
||||||
if (!v) return "—";
|
if (v == null) return "—_-";
|
||||||
|
// ganze oder dezimale Zahl-Strings → Zahl
|
||||||
|
if (typeof v === "string" && /^\d+(\.\d+)?$/.test(v)) v = Number(v);
|
||||||
|
// Sekunden → ms
|
||||||
|
if (typeof v === "number" && v < 1e12) v = v * 1000;
|
||||||
const d = new Date(v);
|
const d = new Date(v);
|
||||||
if (Number.isNaN(d.getTime())) return "—";
|
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString();
|
||||||
return d.toLocaleString();
|
|
||||||
}
|
}
|
||||||
function classNames(...xs) { return xs.filter(Boolean).join(" "); }
|
|
||||||
|
// vereinheitlicht Zeitfelder aus der API
|
||||||
|
function normalizeLog(e) {
|
||||||
|
let t =
|
||||||
|
e.timestamp ?? e.ts ?? e.time ??
|
||||||
|
e.created_at ?? e.createdAt ?? e.created ??
|
||||||
|
e.occurred_at ?? e.date ??
|
||||||
|
(e.meta && e.meta.timestamp) ?? null;
|
||||||
|
|
||||||
|
if (t == null) return { ...e, timestamp: null };
|
||||||
|
|
||||||
|
// Zahl-String (Sekunden oder ms, ggf. mit Dezimalen)
|
||||||
|
if (typeof t === "string" && /^\d+(\.\d+)?$/.test(t)) {
|
||||||
|
const n = Number(t);
|
||||||
|
return { ...e, timestamp: n < 1e12 ? n * 1000 : n };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof t === "number") {
|
||||||
|
return { ...e, timestamp: t < 1e12 ? t * 1000 : t };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof t === "string") {
|
||||||
|
let s = t.trim();
|
||||||
|
s = s.replace(" ", "T"); // "YYYY-MM-DD HH:MM:SS" → "YYYY-MM-DDTHH:MM:SS"
|
||||||
|
s = s.replace(/([+-]\d{2})(\d{2})$/, "$1:$2"); // +0000 → +00:00
|
||||||
|
s = s.replace(/([+-]\d{2})$/, "$1:00"); // +00 → +00:00
|
||||||
|
s = s.replace(/(\.\d{3})\d+/, "$1"); // Mikrosekunden auf ms kürzen
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(s)) s += "Z"; // naive → UTC
|
||||||
|
return { ...e, timestamp: s };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...e, timestamp: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* -------------------------- NiceDropdown (inline) ------------------------ */
|
/* -------------------------- NiceDropdown (inline) ------------------------ */
|
||||||
|
|
||||||
@@ -48,7 +92,7 @@ function NiceDropdown({
|
|||||||
ref={btnRef}
|
ref={btnRef}
|
||||||
onClick={() => setOpen(o => !o)}
|
onClick={() => setOpen(o => !o)}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"w-full bg-gray-800 text-gray-100 rounded-xl border border-gray-600 px-3 py-2",
|
"w-full bg-white/10 text-gray-100 rounded-xl border border-gray-600 px-3 py-2",
|
||||||
"text-left flex items-center justify-between hover:bg-gray-700",
|
"text-left flex items-center justify-between hover:bg-gray-700",
|
||||||
buttonClassName
|
buttonClassName
|
||||||
)}
|
)}
|
||||||
@@ -140,7 +184,7 @@ function UserPicker({ value, onChange, placeholder = "Alle Nutzer" }) {
|
|||||||
type="button"
|
type="button"
|
||||||
ref={btnRef}
|
ref={btnRef}
|
||||||
onClick={() => setOpen(o => !o)}
|
onClick={() => setOpen(o => !o)}
|
||||||
className="w-full bg-gray-800 text-gray-100 rounded-xl border border-gray-600 px-3 py-2 flex items-center justify-between hover:bg-gray-700"
|
className="w-full bg-white/10- text-gray-100 rounded-xl border border-gray-600 px-3 py-2 flex items-center justify-between hover:bg-gray-700"
|
||||||
>
|
>
|
||||||
<span className={value ? "" : "text-white/60"}>
|
<span className={value ? "" : "text-white/60"}>
|
||||||
{selected ? (selected.name || selected.alias || selected.email) : placeholder}
|
{selected ? (selected.name || selected.alias || selected.email) : placeholder}
|
||||||
@@ -155,7 +199,7 @@ function UserPicker({ value, onChange, placeholder = "Alle Nutzer" }) {
|
|||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
className="w-full px-3 py-2 mb-2 rounded-lg bg-gray-800 text-gray-100 border border-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-gray-400/40"
|
className="w-full px-3 py-2 mb-2 rounded-lg bg-white/10 text-gray-100 border border-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-gray-400/40"
|
||||||
placeholder="Suchen…"
|
placeholder="Suchen…"
|
||||||
value={q}
|
value={q}
|
||||||
onChange={(e) => setQ(e.target.value)}
|
onChange={(e) => setQ(e.target.value)}
|
||||||
@@ -231,7 +275,7 @@ export default function LogsPage() {
|
|||||||
date_from,
|
date_from,
|
||||||
date_to,
|
date_to,
|
||||||
});
|
});
|
||||||
const arr = Array.isArray(data) ? data : (data.items || []);
|
const arr = (Array.isArray(data) ? data : (data.items || [])).map(normalizeLog);
|
||||||
setRows((prev) => (reset ? arr : [...prev, ...arr]));
|
setRows((prev) => (reset ? arr : [...prev, ...arr]));
|
||||||
setHasMore(arr.length === LIMIT);
|
setHasMore(arr.length === LIMIT);
|
||||||
setOffset(pageOffset + arr.length);
|
setOffset(pageOffset + arr.length);
|
||||||
@@ -296,14 +340,14 @@ export default function LogsPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
className="ml-auto w-full sm:w-64 px-3 py-2 rounded-xl bg-gray-800 text-gray-100 border border-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-gray-400/40"
|
className="ml-auto w-full sm:w-64 px-3 py-2 rounded-xl bg-white/10 text-gray-100 border border-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-gray-400/40"
|
||||||
placeholder="Suche (User, Aktion, Info)…"
|
placeholder="Suche (User, Aktion, Info)…"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="px-3 py-2 rounded-xl bg-gray-800 border border-gray-600 hover:bg-gray-700 text-gray-100"
|
className="px-3 py-2 rounded-xl bg-white/10 border border-gray-600 hover:bg-gray-700 text-gray-100"
|
||||||
onClick={() => { setUserId(""); setAction(""); setRange("7d"); setQuery(""); load(true); }}
|
onClick={() => { setUserId(""); setAction(""); setRange("7d"); setQuery(""); load(true); }}
|
||||||
>
|
>
|
||||||
Zurücksetzen
|
Zurücksetzen
|
||||||
@@ -325,11 +369,13 @@ export default function LogsPage() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{visible.map((r) => (
|
{visible.map((r) => (
|
||||||
<tr key={r.id ?? `${r.timestamp}-${r.user_id}-${r.action}`} className="border-t border-white/10">
|
<tr key={r.id ?? `${r.timestamp}-${r.user_id}-${r.action}`} className="border-t border-white/10">
|
||||||
<td className="py-2 pr-4 font-mono">{fmtDT(r.timestamp)}</td>
|
<td className="py-2 pr-4 font-mono">
|
||||||
|
{fmtDT(r.timestamp ?? r.created_at ?? r.ts ?? r.time ?? r.date)}
|
||||||
|
</td>
|
||||||
<td className="py-2 pr-4">{r.user?.name ?? r.user?.alias ?? r.user_id}</td>
|
<td className="py-2 pr-4">{r.user?.name ?? r.user?.alias ?? r.user_id}</td>
|
||||||
<td className="py-2 pr-4 font-mono">{String(r.action ?? "—")}</td>
|
<td className="py-2 pr-4 font-mono">{String(r.action ?? "-_-")}</td>
|
||||||
<td className="py-2 pr-4">{r.info ?? "—"}</td>
|
<td className="py-2 pr-4">{r.info ?? "-_-"}</td>
|
||||||
<td className="py-2 pr-4 font-mono">{euroDelta(r.new_balance_cents, r.old_balance_cents) ?? "—"}</td>
|
<td className="py-2 pr-4 font-mono">{euroDelta(r.new_balance_cents, r.old_balance_cents) ?? "-_-"}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{loading && (
|
{loading && (
|
||||||
|
@@ -7,6 +7,8 @@ import {
|
|||||||
updateProduct,
|
updateProduct,
|
||||||
} from "../../api";
|
} from "../../api";
|
||||||
import { FiEye, FiEdit2, FiX, FiPlus, FiTrash2, FiCheck, FiSearch } from "react-icons/fi";
|
import { FiEye, FiEdit2, FiX, FiPlus, FiTrash2, FiCheck, FiSearch } from "react-icons/fi";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
|
||||||
/* ---------- Helpers ---------- */
|
/* ---------- Helpers ---------- */
|
||||||
const euro = (cents) => ((Number(cents ?? 0)) / 100).toFixed(2);
|
const euro = (cents) => ((Number(cents ?? 0)) / 100).toFixed(2);
|
||||||
@@ -160,7 +162,6 @@ const filtered = useMemo(() => {
|
|||||||
setReassignInfo(null);
|
setReassignInfo(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persistentes Speichern aus dem Edit-Dialog
|
|
||||||
async function saveEditedProduct(updated) {
|
async function saveEditedProduct(updated) {
|
||||||
const payload = {
|
const payload = {
|
||||||
name: updated.name,
|
name: updated.name,
|
||||||
@@ -170,12 +171,16 @@ const filtered = useMemo(() => {
|
|||||||
supplier_number: updated.supplier_number ?? null,
|
supplier_number: updated.supplier_number ?? null,
|
||||||
stock: updated.stock,
|
stock: updated.stock,
|
||||||
is_active: updated.is_active,
|
is_active: updated.is_active,
|
||||||
|
// NEU:
|
||||||
|
pack_size: Math.max(1, Number(updated.pack_size) || 1),
|
||||||
|
purchase_price_cents: Number(updated.purchase_price_cents) || 0,
|
||||||
};
|
};
|
||||||
await updateProduct(updated.id, payload);
|
await updateProduct(updated.id, payload);
|
||||||
await refreshProductsAndCategories();
|
await refreshProductsAndCategories();
|
||||||
setEditItem(null);
|
setEditItem(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (loading) return <div className="text-white">Lade Produkte…</div>;
|
if (loading) return <div className="text-white">Lade Produkte…</div>;
|
||||||
if (err) return <div className="text-red-300">Fehler: {String(err.message || err)}</div>;
|
if (err) return <div className="text-red-300">Fehler: {String(err.message || err)}</div>;
|
||||||
|
|
||||||
@@ -340,10 +345,29 @@ const filtered = useMemo(() => {
|
|||||||
/* ---------- Sub-Komponenten ---------- */
|
/* ---------- Sub-Komponenten ---------- */
|
||||||
|
|
||||||
function Modal({ title, children, onClose }) {
|
function Modal({ title, children, onClose }) {
|
||||||
return (
|
// ESC schließt + Body-Scroll locken
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
useEffect(() => {
|
||||||
|
const prevOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
const onKey = (e) => e.key === "Escape" && onClose();
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = prevOverflow;
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const node = (
|
||||||
|
<div className="fixed inset-0 z-[9999]">
|
||||||
|
{/* Backdrop */}
|
||||||
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
|
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
|
||||||
<div className="relative w-full max-w-2xl rounded-2xl bg-slate-900 border border-cyan-400/20 shadow-xl">
|
{/* Centering-Layer */}
|
||||||
|
<div className="absolute inset-0 p-4 flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
className="relative w-full max-w-2xl rounded-2xl bg-slate-900 border border-cyan-400/20 shadow-xl"
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between px-5 py-3 border-b border-white/10">
|
<div className="flex items-center justify-between px-5 py-3 border-b border-white/10">
|
||||||
<h3 className="text-white/90 font-semibold">{title}</h3>
|
<h3 className="text-white/90 font-semibold">{title}</h3>
|
||||||
<button
|
<button
|
||||||
@@ -358,15 +382,26 @@ function Modal({ title, children, onClose }) {
|
|||||||
<div className="p-5 text-white/80">{children}</div>
|
<div className="p-5 text-white/80">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return createPortal(node, document.body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function DetailView({ item }) {
|
function DetailView({ item }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<InfoRow label="ID" value={<code>{item.id}</code>} />
|
<InfoRow label="ID" value={<code>{item.id}</code>} />
|
||||||
<InfoRow label="Name" value={item.name ?? "–"} />
|
<InfoRow label="Name" value={item.name ?? "–"} />
|
||||||
<InfoRow label="Preis" value={`${euro(item.price_cents)} €`} />
|
<InfoRow label="Preis" value={`${euro(item.price_cents)} €`} />
|
||||||
|
{/* NEU: Einkaufspreis & Pack-Größe */}
|
||||||
|
{"purchase_price_cents" in item && (
|
||||||
|
<InfoRow label="Einkaufspreis" value={`${euro(item.purchase_price_cents)} €`} />
|
||||||
|
)}
|
||||||
|
{"pack_size" in item && (
|
||||||
|
<InfoRow label="Pack-Größe" value={displayVal(item.pack_size)} />
|
||||||
|
)}
|
||||||
<InfoRow label="Kategorie" value={item.category ?? "–"} />
|
<InfoRow label="Kategorie" value={item.category ?? "–"} />
|
||||||
{"volume_ml" in item && <InfoRow label="Volumen (ml)" value={displayVal(item.volume_ml)} />}
|
{"volume_ml" in item && <InfoRow label="Volumen (ml)" value={displayVal(item.volume_ml)} />}
|
||||||
<InfoRow label="Bestand" value={displayVal(item.stock)} />
|
<InfoRow label="Bestand" value={displayVal(item.stock)} />
|
||||||
@@ -376,6 +411,7 @@ function DetailView({ item }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function InfoRow({ label, value }) {
|
function InfoRow({ label, value }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between border-b border-white/10 py-2">
|
<div className="flex items-center justify-between border-b border-white/10 py-2">
|
||||||
@@ -392,6 +428,9 @@ function EditForm({ item, onSave, onCancel, categories }) {
|
|||||||
id: item.id,
|
id: item.id,
|
||||||
name: item.name ?? "",
|
name: item.name ?? "",
|
||||||
price_cents: Number(item.price_cents ?? 0),
|
price_cents: Number(item.price_cents ?? 0),
|
||||||
|
// NEU:
|
||||||
|
purchase_price_cents: Number(item.purchase_price_cents ?? 0),
|
||||||
|
pack_size: Number(item.pack_size ?? 1),
|
||||||
category: item.category ?? "",
|
category: item.category ?? "",
|
||||||
stock: item.stock ?? 0,
|
stock: item.stock ?? 0,
|
||||||
volume_ml: item.volume_ml ?? "",
|
volume_ml: item.volume_ml ?? "",
|
||||||
@@ -409,6 +448,9 @@ function EditForm({ item, onSave, onCancel, categories }) {
|
|||||||
...item,
|
...item,
|
||||||
...form,
|
...form,
|
||||||
price_cents: Number(form.price_cents) || 0,
|
price_cents: Number(form.price_cents) || 0,
|
||||||
|
// NEU:
|
||||||
|
purchase_price_cents: Number(form.purchase_price_cents) || 0,
|
||||||
|
pack_size: Math.max(1, Number(form.pack_size) || 1),
|
||||||
stock: form.stock === "" ? null : Number(form.stock),
|
stock: form.stock === "" ? null : Number(form.stock),
|
||||||
volume_ml: form.volume_ml === "" ? null : Number(form.volume_ml),
|
volume_ml: form.volume_ml === "" ? null : Number(form.volume_ml),
|
||||||
});
|
});
|
||||||
@@ -434,7 +476,7 @@ function EditForm({ item, onSave, onCancel, categories }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<Field label="Preis (Cent)">
|
<Field label="Verkaufspreis (Cent)">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className="w-full px-3 py-2 rounded bg-white/10 border border-white/20 text-white"
|
className="w-full px-3 py-2 rounded bg-white/10 border border-white/20 text-white"
|
||||||
@@ -443,6 +485,27 @@ function EditForm({ item, onSave, onCancel, categories }) {
|
|||||||
min={0}
|
min={0}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field label="Einkaufspreis (Cent)">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="w-full px-3 py-2 rounded bg-white/10 border border-white/20 text-white"
|
||||||
|
value={form.purchase_price_cents}
|
||||||
|
onChange={(e) => set("purchase_price_cents", e.target.value)}
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Pack-Größe (Stk/Pack)">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="w-full px-3 py-2 rounded bg-white/10 border border-white/20 text-white"
|
||||||
|
value={form.pack_size}
|
||||||
|
onChange={(e) => set("pack_size", e.target.value)}
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<Field label="Volumen (ml)">
|
<Field label="Volumen (ml)">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -461,9 +524,6 @@ function EditForm({ item, onSave, onCancel, categories }) {
|
|||||||
min={0}
|
min={0}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<Field label="Lieferantennr.">
|
<Field label="Lieferantennr.">
|
||||||
<input
|
<input
|
||||||
className="w-full px-3 py-2 rounded bg-white/10 border border-white/20 text-white"
|
className="w-full px-3 py-2 rounded bg-white/10 border border-white/20 text-white"
|
||||||
@@ -471,7 +531,9 @@ function EditForm({ item, onSave, onCancel, categories }) {
|
|||||||
onChange={(e) => set("supplier_number", e.target.value)}
|
onChange={(e) => set("supplier_number", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<Field label="Aktiv">
|
<Field label="Aktiv">
|
||||||
<Switch
|
<Switch
|
||||||
checked={!!form.is_active}
|
checked={!!form.is_active}
|
||||||
@@ -480,15 +542,7 @@ function EditForm({ item, onSave, onCancel, categories }) {
|
|||||||
labelOff="inaktiv"
|
labelOff="inaktiv"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<Field label="Preis (Anzeige)">
|
|
||||||
<div className="px-3 py-2 rounded bg-white/5 border border-white/10 text-white/80">
|
|
||||||
{euro(form.price_cents)} €
|
|
||||||
</div>
|
|
||||||
</Field>
|
|
||||||
<div />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 pt-2">
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
@@ -510,6 +564,7 @@ function EditForm({ item, onSave, onCancel, categories }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function Field({ label, children }) {
|
function Field({ label, children }) {
|
||||||
return (
|
return (
|
||||||
<label className="block">
|
<label className="block">
|
||||||
|
@@ -15,7 +15,6 @@ import {
|
|||||||
} from "react-icons/fi";
|
} from "react-icons/fi";
|
||||||
|
|
||||||
/* ---------- Geld & PayPal ---------- */
|
/* ---------- Geld & PayPal ---------- */
|
||||||
const euroFmt = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" });
|
|
||||||
|
|
||||||
function parseEuroToCents(input) {
|
function parseEuroToCents(input) {
|
||||||
if (typeof input !== "string") return null;
|
if (typeof input !== "string") return null;
|
||||||
@@ -29,7 +28,7 @@ function parseEuroToCents(input) {
|
|||||||
function genCode5() {
|
function genCode5() {
|
||||||
return String(Math.floor(Math.random() * 100000)).padStart(5, "0");
|
return String(Math.floor(Math.random() * 100000)).padStart(5, "0");
|
||||||
}
|
}
|
||||||
function buildPaypalUrl(amountCents, code) {
|
function buildPaypalUrl(amountCents, code, topupId) {
|
||||||
if (!amountCents || amountCents <= 0) return null;
|
if (!amountCents || amountCents <= 0) return null;
|
||||||
const amountEuro = (amountCents / 100).toFixed(2);
|
const amountEuro = (amountCents / 100).toFixed(2);
|
||||||
const me = import.meta.env.VITE_PAYPAL_ME && String(import.meta.env.VITE_PAYPAL_ME).trim();
|
const me = import.meta.env.VITE_PAYPAL_ME && String(import.meta.env.VITE_PAYPAL_ME).trim();
|
||||||
@@ -45,6 +44,10 @@ function buildPaypalUrl(amountCents, code) {
|
|||||||
amount: amountEuro,
|
amount: amountEuro,
|
||||||
item_name: `Bacchus Top-Up ${code || ""}`,
|
item_name: `Bacchus Top-Up ${code || ""}`,
|
||||||
no_note: "1",
|
no_note: "1",
|
||||||
|
custom: `topup:${topupId || ""}|code:${code || ""}`,
|
||||||
|
notify_url: `${window.location.origin.replace(/\/$/, "")}/api/paypal/ipn`,
|
||||||
|
return: `${window.location.origin}/management/transactions?ok=1`,
|
||||||
|
cancel_return: `${window.location.origin}/management/transactions?canceled=1`,
|
||||||
});
|
});
|
||||||
return `https://www.paypal.com/cgi-bin/webscr?${qs.toString()}`;
|
return `https://www.paypal.com/cgi-bin/webscr?${qs.toString()}`;
|
||||||
}
|
}
|
||||||
@@ -134,6 +137,9 @@ export default function TransactionPage() {
|
|||||||
const [err, setErr] = useState(null);
|
const [err, setErr] = useState(null);
|
||||||
const [balanceCents, setBalanceCents] = useState(null);
|
const [balanceCents, setBalanceCents] = useState(null);
|
||||||
|
|
||||||
|
const euroFmt = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" });
|
||||||
|
const [topupId, setTopupId] = useState(null);
|
||||||
|
|
||||||
const [amountInput, setAmountInput] = useState("");
|
const [amountInput, setAmountInput] = useState("");
|
||||||
const amountCents = useMemo(() => parseEuroToCents(amountInput), [amountInput]);
|
const amountCents = useMemo(() => parseEuroToCents(amountInput), [amountInput]);
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
@@ -195,8 +201,8 @@ export default function TransactionPage() {
|
|||||||
|
|
||||||
const readyToSave = amountCents != null && amountCents > 0;
|
const readyToSave = amountCents != null && amountCents > 0;
|
||||||
const paypalUrl = useMemo(
|
const paypalUrl = useMemo(
|
||||||
() => (saved ? buildPaypalUrl(amountCents, code) : null),
|
() => (saved ? buildPaypalUrl(amountCents, code, topupId) : null),
|
||||||
[saved, amountCents, code]
|
[saved, amountCents, code, topupId]
|
||||||
);
|
);
|
||||||
|
|
||||||
async function onSave() {
|
async function onSave() {
|
||||||
@@ -206,7 +212,8 @@ export default function TransactionPage() {
|
|||||||
setCode(newCode);
|
setCode(newCode);
|
||||||
setCopied(false);
|
setCopied(false);
|
||||||
try {
|
try {
|
||||||
await createTopup(amountCents, newCode); // speichert Top-up + Code
|
const created = await createTopup(amountCents, newCode);
|
||||||
|
setTopupId(created?.id ?? null);
|
||||||
await reloadTx();
|
await reloadTx();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setTxErr(e?.message || "Top-up konnte nicht angelegt werden.");
|
setTxErr(e?.message || "Top-up konnte nicht angelegt werden.");
|
||||||
@@ -230,7 +237,6 @@ export default function TransactionPage() {
|
|||||||
<div className="text-3xl font-mono">
|
<div className="text-3xl font-mono">
|
||||||
{typeof balanceCents === "number" ? euroFmt.format(balanceCents / 100) : "—"}
|
{typeof balanceCents === "number" ? euroFmt.format(balanceCents / 100) : "—"}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-white/50 text-xs mt-1">Stand aus deinem Profil</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Aufladen */}
|
{/* Aufladen */}
|
||||||
|
@@ -541,12 +541,7 @@ async function openViewer(id) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="Name"
|
|
||||||
value={formBase.name}
|
|
||||||
onChange={(v) => { setFormBase(f => ({ ...f, name: v })); setDirty(true); }}
|
|
||||||
inputClassName="bg-gray-800 text-gray-100 border-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-gray-400/40"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Drawer (Bearbeiten) – fixed */}
|
{/* Drawer (Bearbeiten) – fixed */}
|
||||||
{open && (
|
{open && (
|
||||||
@@ -886,22 +881,36 @@ function Th({ children, onClick, active, order }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TextField({ label, value, onChange, type = "text", maxLength, placeholder }) {
|
function TextField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
type = "text",
|
||||||
|
maxLength,
|
||||||
|
placeholder,
|
||||||
|
inputClassName = "",
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<div className="text-white/60 text-xs mb-1">{label}</div>
|
<div className="text-white/60 text-xs mb-1">{label}</div>
|
||||||
<input
|
<input
|
||||||
className="w-full px-3 py-2 rounded-xl bg-gre/5 border border-white/10 text-white/90 placeholder-white/30 focus:outline-none focus:border-cyan-400/40"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
type={type}
|
type={type}
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
maxLength={maxLength}
|
maxLength={maxLength}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
className={cx(
|
||||||
|
"w-full px-3 py-2 rounded-xl border bg-gray-800 text-gray-100",
|
||||||
|
"border-gray-600 placeholder:text-gray-400",
|
||||||
|
"focus:outline-none focus:ring-2 focus:ring-gray-400/40",
|
||||||
|
inputClassName
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function SelectField({ label, value, onChange, options }) {
|
function SelectField({ label, value, onChange, options }) {
|
||||||
return (
|
return (
|
||||||
<label className="block">
|
<label className="block">
|
||||||
|
@@ -13,3 +13,4 @@ export default {
|
|||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
}
|
}
|
||||||
|
|
@@ -1,10 +1,6 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from 'vite';
|
||||||
import react from "@vitejs/plugin-react";
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
|
||||||
port: 3000,
|
|
||||||
open: true
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|