wahnsinn vibe

This commit is contained in:
Marek Lenczewski
2026-04-16 19:42:06 +02:00
parent 9c5da44f64
commit e3e88cc58e
127 changed files with 9456 additions and 3 deletions

View File

@@ -0,0 +1 @@
{"sessionId":"e480a2be-e00b-4a51-8cfc-f525e281eccb","pid":17506,"acquiredAt":1776357371970}

39
.env.example Normal file
View File

@@ -0,0 +1,39 @@
APP_ENV=dev
# Postgres
POSTGRES_USER=shop
POSTGRES_PASSWORD=shop
POSTGRES_DB=shop
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
DATABASE_URL=postgresql+psycopg://shop:shop@localhost:5432/shop
# Redis
REDIS_URL=redis://localhost:6379/0
# Meilisearch
MEILI_URL=http://localhost:7700
MEILI_KEY=shop-dev-master-key
# Ollama
OLLAMA_URL=http://localhost:11434
OLLAMA_CHAT_MODEL=llama3.1
OLLAMA_EMBED_MODEL=nomic-embed-text
OLLAMA_EMBED_DIM=768
# Mail (Mailhog in dev)
SMTP_HOST=localhost
SMTP_PORT=1025
MAIL_FROM=shop@example.com
# Auth
JWT_SECRET=change-me-in-prod-please-32chars-minimum-xxxxx
JWT_ACCESS_MINUTES=15
JWT_REFRESH_DAYS=30
# Upload
UPLOAD_DIR=./uploads
PUBLIC_BASE_URL=http://localhost:8000
# CORS
CORS_ORIGINS=http://localhost:5173,http://localhost:5174

16
.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
.env
__pycache__/
*.pyc
.venv/
.pytest_cache/
.ruff_cache/
node_modules/
dist/
.vite/
.DS_Store
*.log
uploads/*
!uploads/.gitkeep
pgdata/
ollama-data/
meili-data/

View File

@@ -57,11 +57,18 @@ Custom E-Commerce Shopsystem (B2C + B2B), weltweit einsetzbar, als verkaufbares
- Staging: Stripe Testmodus, Catch-All E-Mails
- Production: Echte Zahlungen, Caching an
## Implementierter Stand (Prototyp)
- Alle 8 Module aus `module.md` sind lauffähig: Core, Auth, Catalog, Cart, Checkout, Orders+Mail, AI-Core (RAG/pgvector), AI-Shop-Suche (hybrid), AI-Admin-Chat (Plan/Execute mit Vorschlags-Cards).
- Keine Tests (Prototyp).
- Dummy-Payment, Mailhog für Dev-Mails, Bild-Upload lokal in `./uploads`.
- Starten per `make install && make infra && make migrate && make seed && make reindex && make dev`. Details in `QUICKSTART.md`.
## Analyse-Dokument
- `shopsystem-analyse.tex` — Vollständige Analyse (34 Seiten), nicht mehr anpassen
- `shopsystem-analyse.tex` — Vollständige Analyse (34 Seiten), nicht mehr anpassen.
## Arbeitsweise
- Antworten kompakt halten
- KI-gestützter Workflow: Prototyp → Refactoring → Refactoring
- Antworten kompakt halten.
- KI-gestützter Workflow: Prototyp → Refactoring → Refactoring.

65
Makefile Normal file
View File

@@ -0,0 +1,65 @@
.PHONY: help infra infra-down dev backend shop admin install migrate seed reindex reset logs
help:
@echo "Shopsystem dev commands:"
@echo " make install - install backend + frontend deps"
@echo " make infra - start docker infrastructure (postgres, redis, meili, ollama, mailhog)"
@echo " make infra-down - stop docker infrastructure"
@echo " make migrate - run alembic migrations"
@echo " make seed - seed initial data (admin, demo products, etc.)"
@echo " make reindex - rebuild AI embeddings for all content"
@echo " make dev - run backend + shop + admin in parallel (foreground)"
@echo " make backend - run backend only"
@echo " make shop - run shop frontend only"
@echo " make admin - run admin frontend only"
@echo " make reset - drop DB volume and reinit (destructive)"
@echo " make logs - docker logs for infra"
install:
cd backend && uv sync
cd frontend && pnpm install
infra:
docker compose up -d postgres redis meilisearch ollama mailhog
docker compose up -d ollama-init
@echo "Infrastructure started. Ollama model pull runs in background (can take minutes)."
infra-down:
docker compose down
migrate:
cd backend && uv run alembic upgrade head
seed:
cd backend && uv run python -m core.seed
reindex:
cd backend && uv run python -m apps.ai_core.reindex
dev:
@echo "Starting backend (8000), shop (5173), admin (5174)..."
@( cd backend && uv run uvicorn core.main:app --reload --port 8000 ) & \
( cd frontend/shop && pnpm dev --port 5173 ) & \
( cd frontend/admin && pnpm dev --port 5174 ) & \
wait
backend:
cd backend && uv run uvicorn core.main:app --reload --port 8000
shop:
cd frontend/shop && pnpm dev --port 5173
admin:
cd frontend/admin && pnpm dev --port 5174
reset:
docker compose down -v
rm -rf pgdata meili-data
$(MAKE) infra
sleep 5
$(MAKE) migrate
$(MAKE) seed
$(MAKE) reindex
logs:
docker compose logs -f --tail=100

126
QUICKSTART.md Normal file
View File

@@ -0,0 +1,126 @@
# Shopsystem — Prototyp Quickstart
## Was ist das?
Ein app-basiertes E-Commerce-Shopsystem als **Prototyp**, um ein Gefühl für das Konzept zu bekommen. 8 Module sind end-to-end implementiert (Core, Auth, Catalog, Cart/Checkout, Orders/Mail, AI-Core, AI-Shop-Suche, AI-Admin-Chat mit Vorschlags-Cards).
## Voraussetzungen
- Docker + `docker compose`
- Python ≥ 3.11 + `uv` (`curl -LsSf https://astral.sh/uv/install.sh | sh`)
- Node ≥ 20 + `pnpm` (`npm install -g pnpm`)
## Einmalig: Installation
```bash
make install # installiert backend (uv) + frontend (pnpm)
make infra # startet postgres, redis, meilisearch, ollama, mailhog
# Ollama zieht im Hintergrund llama3.1 (~5 GB) + nomic-embed-text (~270 MB).
# Beim ersten Start dauert das je nach Bandbreite 1020 min.
make migrate # erstellt Tabellen
make seed # legt Admin, Demo-Kunde, 4 Kategorien, 12 Produkte an
make reindex # baut KI-Embeddings (braucht nomic-embed-text)
```
## Starten
```bash
make dev # startet Backend + Shop + Admin parallel im Vordergrund
# Backend: http://localhost:8000 (OpenAPI-Docs: /docs)
# Shop: http://localhost:5173
# Admin: http://localhost:5174
# Mailhog: http://localhost:8025
```
Oder einzeln: `make backend`, `make shop`, `make admin`.
## Logins (Seed)
- Admin: `admin@example.com` / `admin123`
- Demo-Kunde: `kunde@example.com` / `kunde123`
## Klick-Route durch alle Features
### Shop (http://localhost:5173)
1. **Startseite** — 12 Produkte, 4 Kategorien
2. **KI-Suche** (`/ai` oder „KI-Suche" im Header):
- `grüner Pulli` → der „Grüne Kuschelpulli" erscheint ganz oben
- `etwas warmes zum Wandern` → Outdoor-Jacke, Wanderschuhe, Wanderhose
3. **Registrierung** (`/register`) oder **Login** (`/login`, Demo-Kunde vorausgefüllt)
4. Produkt öffnen → „In den Warenkorb" → `/cart`**Zur Kasse**
5. Adresse ausfüllen → **Jetzt bestellen** → Bestätigung mit Order-ID
6. Auf Mailhog (http://localhost:8025) liegt die **Bestellbestätigung**
7. `/account/orders` — Bestellung sichtbar
### Admin (http://localhost:5174)
1. Login mit Admin-Credentials (vorausgefüllt)
2. **Dashboard** — Statistiken + KI-Chatbox
3. **KI-Chatbox-Beispiele**:
- Typ: `setze den Shopnamen auf TEST123`**Planen** → Card erscheint → **Bestätigen** → Shop-Header zeigt TEST123
- JSON-Bulk reinwerfen:
```
Das sind neue Produkte, erstelle sie:
[
{"sku":"NEW-TS-RED","name_de":"Rotes T-Shirt","price":18.90,"stock":20},
{"sku":"NEW-SOCK","name_de":"Socken","price":4.90,"stock":100},
{"sku":"NEW-CAP","name_de":"Grüne Kappe","price":12.00,"stock":30}
]
```
→ 3 Cards → Felder editierbar → **Alle bestätigen** → 3 neue Produkte im Shop
4. **Produkte** — Liste, neu anlegen (mit Bild-Upload), bearbeiten, löschen
5. **Kategorien** — CRUD
6. **Bestellungen** — Liste + Detail + Statuswechsel (paid → shipped → delivered …)
7. **Einstellungen** — Shopname ändern, KI-Index neu bauen
## Nützliche Kommandos
```bash
make reindex # nach großen Produkt-Änderungen oder bei KI-Suche-Problemen
make reset # destruktiv: DB-Volume löschen, alles neu
make logs # docker logs
make infra-down # stoppt docker-services (dev-Prozesse bleiben)
```
## Bekannte Grenzen (bewusst so im Prototyp)
- Keine Tests (pytest/vitest weggelassen)
- Nur Dummy-Payment
- Bild-Upload lokal in `./uploads`
- KI-Shop-Suche: Hybrid aus Embedding + Keyword-Boost (nomic-embed-text ist auf Deutsch allein zu schwach)
- KI-Admin-Chat nutzt llama3.1 — Antwort-Qualität hängt vom Modell ab. Bei schwachen Prompts kann die Card ungenau sein; Missing-Felder werden aber hart validiert
- Meilisearch-Service läuft, ist aber nicht angebunden (für später)
- Ein Admin-Panel-Logout via Sidebar lädt Login-Seite
- Responsive, aber mobile-first-Polish fehlt
## Architektur-Überblick
```
Browser
├── http://localhost:5173 → Vue Shop (Kundensicht)
└── http://localhost:5174 → Vue Admin (KI-Chat, CRUD)
http://localhost:8000 FastAPI
├── core (App-Loader, Event-Bus, DI, Settings, Redis-Projector, Security)
└── apps/*
auth, catalog, cart, checkout, payment, orders, mail,
ai_core (pgvector + Ollama + Tools), ai_shop (hybrid search),
ai_admin (Plan/Execute mit Proposal-Cards)
┌─────────────┼─────────────────┬─────────────┐
▼ ▼ ▼ ▼
Postgres Redis Ollama Mailhog
(Write + (Read-Cache: (llama3.1 (Dev-SMTP,
pgvector) Products, Chat + Web-UI 8025)
Categories, nomic
Settings) Embed)
```
## Troubleshooting
- **„KI-Suche gibt nichts/Unsinn":** `make reindex` ausführen. Wenn nomic-embed-text nicht gezogen ist, siehe `docker compose logs ollama-init`.
- **„KI-Chatbox gibt nur _error zurück":** Das llama3.1-Modell ist noch am Pullen. Warten bis in `curl http://localhost:11434/api/tags` beide Modelle auftauchen.
- **„Port schon belegt":** `pkill -f vite; pkill -f uvicorn` dann `make dev`.
- **„DB-Schema-Mismatch":** `make reset` (löscht alles und baut neu).

39
backend/alembic.ini Normal file
View File

@@ -0,0 +1,39 @@
[alembic]
script_location = core/migrations
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url =
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
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

0
backend/apps/__init__.py Normal file
View File

View File

@@ -0,0 +1,72 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from core.db import get_db
from core.security import require_admin
from apps.ai_core.models import AIAuditLog
from apps.ai_core.tools import get_tool, validate_args
from .planner import build_plan
from .tool_defs import register_all
router = APIRouter(dependencies=[Depends(require_admin)])
class PlanIn(BaseModel):
prompt: str
class ExecuteCardIn(BaseModel):
tool: str
args: dict = {}
class ExecuteIn(BaseModel):
cards: list[ExecuteCardIn]
@router.post("/plan")
def plan_endpoint(body: PlanIn):
if not body.prompt.strip():
raise HTTPException(400, "Empty prompt")
cards = build_plan(body.prompt)
return {"cards": cards}
@router.post("/execute")
def execute_endpoint(
body: ExecuteIn,
claims: dict = Depends(require_admin),
db: Session = Depends(get_db),
):
user_id = int(claims["sub"])
results = []
for card in body.cards:
spec = get_tool(card.tool)
if not spec:
results.append({"tool": card.tool, "ok": False, "error": "unknown tool"})
db.add(AIAuditLog(user_id=user_id, tool=card.tool, args=card.args, result={"error": "unknown tool"}, ok=False))
db.commit()
continue
missing = validate_args(spec, card.args)
if missing:
results.append({"tool": card.tool, "ok": False, "error": f"missing: {missing}"})
db.add(AIAuditLog(user_id=user_id, tool=card.tool, args=card.args, result={"missing": missing}, ok=False))
db.commit()
continue
try:
res = spec.handler(card.args, db)
results.append({"tool": card.tool, "ok": True, "result": res})
db.add(AIAuditLog(user_id=user_id, tool=card.tool, args=card.args, result=res, ok=True))
db.commit()
except Exception as e: # noqa: BLE001
results.append({"tool": card.tool, "ok": False, "error": str(e)})
db.add(AIAuditLog(user_id=user_id, tool=card.tool, args=card.args, result={"error": str(e)}, ok=False))
db.commit()
return {"results": results}
def on_load() -> None:
register_all()

View File

@@ -0,0 +1,6 @@
name: ai_admin
version: 0.1.0
depends_on: [core, auth, catalog, orders, ai_core]
conflicts_with: []
required: false
provides: []

View File

@@ -0,0 +1,113 @@
"""Build a structured action plan from a natural-language prompt (or JSON bulk)."""
from __future__ import annotations
import json
from core.db import SessionLocal
from apps.ai_core.ollama_client import get_llm
from apps.ai_core.tools import describe_for_prompt, get_tool, validate_args
from apps.catalog.models import Category, Product
SYSTEM_PROMPT = """You are an admin assistant for an e-commerce shop.
You help the operator perform tasks by producing a STRUCTURED PLAN of tool calls.
You MUST NEVER execute anything. You only propose cards that the operator confirms.
Output format (STRICT):
Reply with ONLY a JSON object of the shape {"cards": [ ... ]}.
Each card: {"tool": "<tool-name>", "args": {...}, "missing": [], "preview": "German summary", "notes": ""}.
Rules:
- Only use tools from the provided TOOL CATALOG. If no tool applies, return {"cards": []}.
- NEVER emit a single card that aggregates multiple items.
If the user provides JSON with multiple items (bulk) → produce ONE card per item.
If the user asks to change something about ALL or MULTIPLE existing products/categories
→ produce ONE card per matching item from the SHOP STATE snapshot, with its exact id.
Examples:
"setze alle preise auf 1" on 3 products → 3 cards, each {"tool":"catalog.product.update","args":{"id":<real id>,"price":1},...}
NOT one card with id=null or "all".
- Numbers must be numbers in JSON (not strings). Omit optional fields instead of sending null.
- Stay concise in "preview".
"""
def _shop_state_snapshot() -> dict:
"""Compact snapshot of current shop state for the planner. Keep it small."""
db = SessionLocal()
try:
products = [
{"id": p.id, "sku": p.sku, "name_de": (p.name or {}).get("de", ""), "price": float(p.price)}
for p in db.query(Product).order_by(Product.id).all()
]
categories = [
{"id": c.id, "slug": c.slug, "name_de": (c.name or {}).get("de", "")}
for c in db.query(Category).order_by(Category.id).all()
]
return {"products": products, "categories": categories}
finally:
db.close()
def build_plan(user_prompt: str) -> list[dict]:
tools = describe_for_prompt(role="admin")
state = _shop_state_snapshot()
user_msg = (
f"TOOL CATALOG (JSON):\n{json.dumps(tools, ensure_ascii=False)}\n\n"
f"SHOP STATE (current products & categories):\n{json.dumps(state, ensure_ascii=False)}\n\n"
f"USER REQUEST:\n{user_prompt}\n\n"
"Reply with ONLY the JSON object described in the rules."
)
try:
result = get_llm().chat_json(SYSTEM_PROMPT, user_msg)
except Exception as e: # noqa: BLE001
return [
{
"tool": "_error",
"args": {},
"missing": [],
"preview": f"Planer-Fehler: {e}",
"notes": "LLM antwortete nicht verwertbar. Prompt umformulieren.",
}
]
if isinstance(result, list):
cards = result
elif isinstance(result, dict) and isinstance(result.get("cards"), list):
cards = result["cards"]
else:
cards = []
# Validate and annotate
clean: list[dict] = []
for card in cards:
if not isinstance(card, dict):
continue
name = card.get("tool", "")
args = card.get("args") or {}
spec = get_tool(name)
if spec:
# Trust only server-side required-field validation, not LLM-supplied missing
missing = validate_args(spec, args)
clean.append(
{
"tool": name,
"args": args,
"missing": missing,
"preview": card.get("preview", ""),
"notes": card.get("notes", ""),
"schema": spec.args_schema,
}
)
else:
clean.append(
{
"tool": name,
"args": args,
"missing": list(card.get("missing") or []),
"preview": card.get("preview", f"Unbekanntes Tool: {name}"),
"notes": "tool not in catalog",
"schema": {"type": "object", "properties": {}},
}
)
return clean

View File

@@ -0,0 +1,227 @@
"""Tool definitions — Admin-facing actions the KI can plan.
Each tool: name, description, JSON Schema for args, and a handler that is only
ever called from the `execute` endpoint after the user confirmed the Card.
"""
from __future__ import annotations
from sqlalchemy.orm import Session
from core.events import event_bus
from core.settings_service import set_setting
from apps.ai_core.tools import ToolSpec, register_tool
from apps.catalog.models import Category, Product
from apps.catalog.projector import project_category, project_product
# ---- settings.update ------------------------------------------------
def _handler_settings_update(args: dict, db: Session) -> dict:
key = args["key"]
value = args["value"]
set_setting(db, key, value)
return {"key": key, "value": value}
SETTINGS_UPDATE = ToolSpec(
name="settings.update",
description="Update a shop-wide setting (e.g. shop name, currency, support email).",
args_schema={
"type": "object",
"required": ["key", "value"],
"properties": {
"key": {
"type": "string",
"description": "Setting key, e.g. 'core.shop_name'.",
},
"value": {
"description": "New value (string / number / boolean).",
},
},
},
handler=_handler_settings_update,
examples=[
{"key": "core.shop_name", "value": "TEST123"},
{"key": "core.support_email", "value": "help@example.com"},
],
)
# ---- catalog.product.create ----------------------------------------
def _coalesce(value, default):
"""Return default if value is None or missing; otherwise the value."""
return default if value is None else value
def _handler_product_create(args: dict, db: Session) -> dict:
if db.query(Product).filter_by(sku=args["sku"]).first():
raise ValueError(f"SKU already exists: {args['sku']}")
name_de = _coalesce(args.get("name_de"), "")
name_en = _coalesce(args.get("name_en"), name_de)
desc_de = _coalesce(args.get("description_de"), "")
desc_en = _coalesce(args.get("description_en"), desc_de)
category_id = args.get("category_id")
if category_id in ("", 0):
category_id = None
p = Product(
sku=args["sku"],
name={"de": name_de, "en": name_en},
description={"de": desc_de, "en": desc_en},
price=float(args["price"]),
currency=_coalesce(args.get("currency"), "EUR") or "EUR",
stock=int(_coalesce(args.get("stock"), 0) or 0),
active=bool(_coalesce(args.get("active"), True)),
image_url=_coalesce(args.get("image_url"), "") or "",
category_id=category_id,
attributes=_coalesce(args.get("attributes"), {}) or {},
)
db.add(p)
db.commit()
db.refresh(p)
project_product(db, p.id)
event_bus.publish("product.created", {"id": p.id, "sku": p.sku}, db=db)
return {"id": p.id, "sku": p.sku}
PRODUCT_CREATE = ToolSpec(
name="catalog.product.create",
description="Create a new product in the catalog.",
args_schema={
"type": "object",
"required": ["sku", "name_de", "price"],
"properties": {
"sku": {"type": "string"},
"name_de": {"type": "string"},
"name_en": {"type": "string"},
"description_de": {"type": "string"},
"description_en": {"type": "string"},
"price": {"type": "number"},
"currency": {"type": "string", "default": "EUR"},
"stock": {"type": "integer", "default": 0},
"active": {"type": "boolean", "default": True},
"image_url": {"type": "string"},
"category_id": {"type": "integer"},
"attributes": {"type": "object"},
},
},
handler=_handler_product_create,
examples=[
{
"sku": "TS-GREEN-M",
"name_de": "Grünes T-Shirt",
"name_en": "Green T-Shirt",
"price": 19.90,
"stock": 42,
"attributes": {"color": "green", "size": "M"},
}
],
)
# ---- catalog.product.update ----------------------------------------
def _handler_product_update(args: dict, db: Session) -> dict:
pid = int(args["id"])
p = db.get(Product, pid)
if not p:
raise ValueError(f"Product {pid} not found")
if "name_de" in args or "name_en" in args:
p.name = {
"de": args.get("name_de", p.name.get("de", "")),
"en": args.get("name_en", p.name.get("en", "")),
}
if "description_de" in args or "description_en" in args:
p.description = {
"de": args.get("description_de", p.description.get("de", "")),
"en": args.get("description_en", p.description.get("en", "")),
}
for f in ("price", "currency", "stock", "active", "image_url", "category_id"):
if f in args:
setattr(p, f, args[f])
if "attributes" in args:
p.attributes = args["attributes"]
db.commit()
db.refresh(p)
project_product(db, p.id)
event_bus.publish("product.updated", {"id": p.id}, db=db)
return {"id": p.id, "sku": p.sku}
PRODUCT_UPDATE = ToolSpec(
name="catalog.product.update",
description="Update fields of an existing product.",
args_schema={
"type": "object",
"required": ["id"],
"properties": {
"id": {"type": "integer"},
"name_de": {"type": "string"},
"name_en": {"type": "string"},
"description_de": {"type": "string"},
"description_en": {"type": "string"},
"price": {"type": "number"},
"stock": {"type": "integer"},
"active": {"type": "boolean"},
"image_url": {"type": "string"},
"category_id": {"type": "integer"},
"attributes": {"type": "object"},
},
},
handler=_handler_product_update,
examples=[{"id": 5, "price": 24.90, "stock": 10}],
)
# ---- catalog.category.create --------------------------------------
def _handler_category_create(args: dict, db: Session) -> dict:
if db.query(Category).filter_by(slug=args["slug"]).first():
raise ValueError(f"Slug exists: {args['slug']}")
c = Category(
slug=args["slug"],
name={"de": args.get("name_de", ""), "en": args.get("name_en", args.get("name_de", ""))},
parent_id=args.get("parent_id"),
sort_order=int(args.get("sort_order", 0)),
)
db.add(c)
db.commit()
db.refresh(c)
project_category(db, c.id)
event_bus.publish("category.created", {"id": c.id}, db=db)
return {"id": c.id, "slug": c.slug}
CATEGORY_CREATE = ToolSpec(
name="catalog.category.create",
description="Create a new category.",
args_schema={
"type": "object",
"required": ["slug", "name_de"],
"properties": {
"slug": {"type": "string"},
"name_de": {"type": "string"},
"name_en": {"type": "string"},
"parent_id": {"type": "integer"},
"sort_order": {"type": "integer"},
},
},
handler=_handler_category_create,
examples=[{"slug": "accessoires", "name_de": "Accessoires", "name_en": "Accessories"}],
)
# ---- registration -------------------------------------------------
ALL_TOOLS = [SETTINGS_UPDATE, PRODUCT_CREATE, PRODUCT_UPDATE, CATEGORY_CREATE]
def register_all() -> None:
for t in ALL_TOOLS:
register_tool(t)

View File

@@ -0,0 +1,69 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.orm import Session
from core.db import get_db
from core.di import register_service
from core.security import require_admin
from .indexer import reindex_all, subscribe_indexer
from .ollama_client import get_llm
from .tools import describe_for_prompt, list_tools
router = APIRouter()
class QueryIn(BaseModel):
query: str
source_type: str | None = None
limit: int = 10
@router.post("/query")
def query_rag(body: QueryIn, db: Session = Depends(get_db)):
if not body.query.strip():
raise HTTPException(400, "Empty query")
emb = get_llm().embed(body.query)
stmt = text(
"""
SELECT source_type, source_id, text, meta,
1 - (embedding <=> (:q)::vector) AS score
FROM ai_documents
{where}
ORDER BY embedding <=> (:q)::vector
LIMIT :lim
""".format(
where="WHERE source_type = :st" if body.source_type else ""
)
)
params: dict = {"q": emb, "lim": body.limit}
if body.source_type:
params["st"] = body.source_type
rows = db.execute(stmt, params).mappings().all()
return [
{
"source_type": r["source_type"],
"source_id": r["source_id"],
"text": r["text"],
"meta": r["meta"],
"score": float(r["score"]),
}
for r in rows
]
@router.post("/reindex", dependencies=[Depends(require_admin)])
def trigger_reindex():
return reindex_all()
@router.get("/tools")
def catalog(_: dict = Depends(require_admin)):
return describe_for_prompt()
def on_load() -> None:
subscribe_indexer()
register_service("LLMProvider", get_llm())
register_service("ToolRegistry", list_tools)

View File

@@ -0,0 +1,158 @@
"""RAG indexer: subscribes to product/category/setting events and (re)builds embeddings."""
from __future__ import annotations
from typing import Any
from sqlalchemy import delete
from sqlalchemy.orm import Session
from core.db import SessionLocal
from core.events import event_bus
from core.i18n import pick
from apps.catalog.models import Category, Product
from .models import AIDocument
from .ollama_client import get_llm
_COLOR_SYNONYMS = {
"green": "grün grüner grünes Grün green olive",
"blue": "blau blauer blaues Blau blue navy",
"black": "schwarz schwarzer schwarzes Schwarz black",
"white": "weiß weißer weißes Weiß white blank",
"olive": "oliv olivgrün olive green khaki",
"red": "rot roter rotes Rot red",
"khaki": "khaki beige oliv",
"brown": "braun brauner braunes Braun brown",
"grey": "grau grauer graues Grau grey gray",
}
def _product_text(p: Product, cat: Category | None) -> str:
parts = [
pick(p.name, "de"),
pick(p.name, "en"),
pick(p.description, "de"),
pick(p.description, "en"),
]
if cat:
parts.append(pick(cat.name, "de"))
parts.append(pick(cat.name, "en"))
if p.attributes:
for k, v in p.attributes.items():
parts.append(f"{k}: {v}")
if k == "color" and isinstance(v, str) and v in _COLOR_SYNONYMS:
parts.append(_COLOR_SYNONYMS[v])
return "\n".join([s for s in parts if s])
def _category_text(c: Category) -> str:
return "\n".join([pick(c.name, "de"), pick(c.name, "en")])
def _upsert(db: Session, source_type: str, source_id: str, text: str, meta: dict) -> None:
if not text.strip():
return
embedding = get_llm().embed(text)
existing = (
db.query(AIDocument)
.filter_by(source_type=source_type, source_id=source_id)
.first()
)
if existing:
existing.text = text
existing.embedding = embedding
existing.meta = meta
else:
db.add(
AIDocument(
source_type=source_type,
source_id=source_id,
text=text,
embedding=embedding,
meta=meta,
)
)
db.commit()
def _remove(db: Session, source_type: str, source_id: str) -> None:
db.execute(
delete(AIDocument).where(
AIDocument.source_type == source_type,
AIDocument.source_id == source_id,
)
)
db.commit()
def index_product(db: Session, product_id: int) -> None:
p = db.get(Product, product_id)
if not p:
_remove(db, "product", str(product_id))
return
cat = db.get(Category, p.category_id) if p.category_id else None
text = _product_text(p, cat)
meta = {"category_id": p.category_id, "price": float(p.price)}
_upsert(db, "product", str(product_id), text, meta)
def index_category(db: Session, category_id: int) -> None:
c = db.get(Category, category_id)
if not c:
_remove(db, "category", str(category_id))
return
_upsert(db, "category", str(category_id), _category_text(c), {})
def reindex_all() -> dict:
db = SessionLocal()
try:
db.execute(delete(AIDocument))
db.commit()
n_p = 0
for p in db.query(Product).filter(Product.active.is_(True)).all():
index_product(db, p.id)
n_p += 1
n_c = 0
for c in db.query(Category).all():
index_category(db, c.id)
n_c += 1
return {"products": n_p, "categories": n_c}
finally:
db.close()
# Event subscribers -----------------------------------------------------
def _on_product_event(event_type: str, payload: dict[str, Any], db: Session) -> None:
pid = payload.get("id")
if not pid:
return
if event_type == "product.deleted":
_remove(db, "product", str(pid))
else:
try:
index_product(db, pid)
except Exception as e: # noqa: BLE001
print(f"[ai-indexer] product {pid} failed: {e}")
def _on_category_event(event_type: str, payload: dict[str, Any], db: Session) -> None:
cid = payload.get("id")
if not cid:
return
if event_type == "category.deleted":
_remove(db, "category", str(cid))
else:
try:
index_category(db, cid)
except Exception as e: # noqa: BLE001
print(f"[ai-indexer] category {cid} failed: {e}")
def subscribe_indexer() -> None:
event_bus.subscribe("product.*", _on_product_event)
event_bus.subscribe("category.*", _on_category_event)

View File

@@ -0,0 +1,6 @@
name: ai_core
version: 0.1.0
depends_on: [core, catalog]
conflicts_with: []
required: true
provides: [LLMProvider, ToolRegistry]

View File

@@ -0,0 +1,34 @@
from datetime import datetime
from pgvector.sqlalchemy import Vector
from sqlalchemy import JSON, DateTime, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from core.config import settings
from core.db import Base
class AIDocument(Base):
__tablename__ = "ai_documents"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
source_type: Mapped[str] = mapped_column(String(64), index=True) # 'product', 'category', 'setting'
source_id: Mapped[str] = mapped_column(String(64), index=True)
text: Mapped[str] = mapped_column(Text)
embedding: Mapped[list[float]] = mapped_column(Vector(settings.OLLAMA_EMBED_DIM))
meta: Mapped[dict] = mapped_column(JSON, default=dict)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class AIAuditLog(Base):
__tablename__ = "ai_audit"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
tool: Mapped[str] = mapped_column(String(128))
args: Mapped[dict] = mapped_column(JSON, default=dict)
result: Mapped[dict] = mapped_column(JSON, default=dict)
ok: Mapped[bool] = mapped_column()
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View File

@@ -0,0 +1,69 @@
"""Thin Ollama client. Interface is kept minimal so Ollama can be swapped later."""
from __future__ import annotations
import json
import httpx
from core.config import settings
class OllamaClient:
def __init__(self, base_url: str | None = None) -> None:
self.base_url = base_url or settings.OLLAMA_URL
self._client = httpx.Client(base_url=self.base_url, timeout=600.0)
def embed(self, text: str, model: str | None = None) -> list[float]:
model = model or settings.OLLAMA_EMBED_MODEL
r = self._client.post("/api/embeddings", json={"model": model, "prompt": text})
r.raise_for_status()
data = r.json()
return data["embedding"]
def chat(
self,
system: str,
user: str,
model: str | None = None,
json_mode: bool = False,
) -> str:
model = model or settings.OLLAMA_CHAT_MODEL
messages = [
{"role": "system", "content": system},
{"role": "user", "content": user},
]
payload = {"model": model, "messages": messages, "stream": False}
if json_mode:
payload["format"] = "json"
r = self._client.post("/api/chat", json=payload)
r.raise_for_status()
data = r.json()
return data.get("message", {}).get("content", "")
def chat_json(self, system: str, user: str, model: str | None = None) -> dict:
raw = self.chat(system, user, model=model, json_mode=True)
try:
return json.loads(raw)
except json.JSONDecodeError:
# Try to recover a JSON object/array from the output
start = raw.find("{")
alt_start = raw.find("[")
if alt_start != -1 and (start == -1 or alt_start < start):
start = alt_start
end = max(raw.rfind("}"), raw.rfind("]"))
if start >= 0 and end > start:
try:
return json.loads(raw[start : end + 1])
except json.JSONDecodeError:
pass
raise
_client: OllamaClient | None = None
def get_llm() -> OllamaClient:
global _client
if _client is None:
_client = OllamaClient()
return _client

View File

@@ -0,0 +1,6 @@
"""Standalone entrypoint: `uv run python -m apps.ai_core.reindex`"""
from .indexer import reindex_all
if __name__ == "__main__":
result = reindex_all()
print(f"Reindexed: {result}")

View File

@@ -0,0 +1,56 @@
"""Tool Registry: apps register callable tools + JSON Schema for the KI to use.
KI never runs tools directly — the registry is only a catalog for the planner,
and handlers are invoked by the `execute` endpoint after user confirmation.
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
from sqlalchemy.orm import Session
@dataclass
class ToolSpec:
name: str # e.g. "catalog.product.create"
description: str
args_schema: dict # JSON Schema
handler: Callable[[dict, Session], dict]
required_role: str = "admin" # only admin-exposed in AI admin chat
examples: list[dict] = field(default_factory=list)
_tools: dict[str, ToolSpec] = {}
def register_tool(spec: ToolSpec) -> None:
_tools[spec.name] = spec
def get_tool(name: str) -> ToolSpec | None:
return _tools.get(name)
def list_tools(role: str = "admin") -> list[ToolSpec]:
return [t for t in _tools.values() if t.required_role == role or role == "admin"]
def describe_for_prompt(role: str = "admin") -> list[dict[str, Any]]:
"""Return a JSON-serializable description of all tools for the LLM prompt."""
return [
{
"name": t.name,
"description": t.description,
"args_schema": t.args_schema,
"examples": t.examples,
}
for t in list_tools(role)
]
def validate_args(spec: ToolSpec, args: dict) -> list[str]:
"""Return list of missing required keys (basic check — not full JSON-schema)."""
required = spec.args_schema.get("required", [])
return [k for k in required if k not in args or args[k] in (None, "")]

View File

@@ -0,0 +1,111 @@
import json
import re
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.orm import Session
from core.db import get_db
from core.i18n import pick
from core.redis_client import redis_client
from apps.ai_core.ollama_client import get_llm
router = APIRouter()
class SearchIn(BaseModel):
query: str
limit: int = 12
_STOP = {
"ich", "suche", "brauche", "will", "möchte", "eine", "einen", "ein",
"die", "der", "das", "und", "oder", "mit", "für", "zum", "zur",
"i", "am", "looking", "for", "want", "need", "a", "an", "the",
"of", "to", "with", "etwas", "some", "nach",
}
_SYN = {
"pulli": ["pullover", "sweater"],
"shirt": ["t-shirt", "tshirt"],
"hose": ["pants", "jeans"],
"warme": ["warm"],
"warm": ["warme"],
"grüner": ["grün", "green"],
"grüne": ["grün", "green"],
"grünes": ["grün", "green"],
"blauer": ["blau", "blue"],
"blaue": ["blau", "blue"],
"blaues": ["blau", "blue"],
"wandern": ["wander", "hiking"],
}
def _tokenize(s: str) -> list[str]:
tokens = [t.lower() for t in re.findall(r"[\wäöüß]+", s, flags=re.UNICODE)]
expanded: list[str] = []
for t in tokens:
if t in _STOP or len(t) < 2:
continue
expanded.append(t)
expanded.extend(_SYN.get(t, []))
return expanded
def _keyword_score(product: dict, tokens: list[str]) -> float:
if not tokens:
return 0.0
haystack = " ".join([
pick(product.get("name", {}), "de").lower(),
pick(product.get("name", {}), "en").lower(),
pick(product.get("description", {}), "de").lower(),
pick(product.get("description", {}), "en").lower(),
" ".join(str(v).lower() for v in (product.get("attributes") or {}).values()),
product.get("sku", "").lower(),
])
hits = sum(1 for t in tokens if t in haystack)
return hits / len(tokens)
@router.post("/search")
def ki_search(body: SearchIn, db: Session = Depends(get_db)):
"""Hybrid product search: embedding similarity + keyword boost."""
q = body.query.strip()
if not q:
return {"query": q, "products": []}
emb = get_llm().embed(q)
# Pull a larger candidate pool, then re-rank with keyword boost
pool_size = max(body.limit * 3, 20)
rows = db.execute(
text(
"""
SELECT source_id, 1 - (embedding <=> (:q)::vector) AS score
FROM ai_documents
WHERE source_type = 'product'
ORDER BY embedding <=> (:q)::vector
LIMIT :lim
"""
),
{"q": emb, "lim": pool_size},
).mappings().all()
tokens = _tokenize(q)
candidates: list[dict] = []
for r in rows:
raw = redis_client.get(f"product:{r['source_id']}")
if not raw:
continue
d = json.loads(raw)
emb_s = float(r["score"])
kw_s = _keyword_score(d, tokens)
# Combined score: 60% embedding, 40% keyword (but keyword zeroing-out boosts ordering)
d["_score"] = round(0.6 * emb_s + 0.4 * kw_s, 4)
d["_emb"] = round(emb_s, 4)
d["_kw"] = round(kw_s, 4)
candidates.append(d)
candidates.sort(key=lambda p: p["_score"], reverse=True)
return {"query": q, "products": candidates[: body.limit]}

View File

@@ -0,0 +1,6 @@
name: ai_shop
version: 0.1.0
depends_on: [core, catalog, ai_core]
conflicts_with: []
required: false
provides: []

View File

@@ -0,0 +1,155 @@
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, EmailStr
from sqlalchemy.orm import Session
from core.db import get_db
from core.events import event_bus
from core.security import (
decode_token,
get_current_user_id,
hash_password,
make_access_token,
make_refresh_token,
verify_password,
)
from .models import User
router = APIRouter()
class RegisterIn(BaseModel):
email: EmailStr
password: str
name: str = ""
class LoginIn(BaseModel):
email: EmailStr
password: str
class TokenOut(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
role: str
user_id: int
class UserOut(BaseModel):
id: int
email: str
name: str
role: str
locale: str
class UpdateMeIn(BaseModel):
name: str | None = None
locale: str | None = None
class ChangePasswordIn(BaseModel):
old_password: str
new_password: str
@router.post("/register", response_model=TokenOut)
def register(body: RegisterIn, db: Session = Depends(get_db)):
if db.query(User).filter_by(email=body.email.lower()).first():
raise HTTPException(400, "Email already registered")
user = User(
email=body.email.lower(),
password_hash=hash_password(body.password),
name=body.name,
role="customer",
)
db.add(user)
db.commit()
db.refresh(user)
event_bus.publish(
"user.registered",
{"user_id": user.id, "email": user.email},
db=db,
)
return TokenOut(
access_token=make_access_token(user.id, user.role),
refresh_token=make_refresh_token(user.id, user.role),
role=user.role,
user_id=user.id,
)
@router.post("/login", response_model=TokenOut)
def login(body: LoginIn, db: Session = Depends(get_db)):
user = db.query(User).filter_by(email=body.email.lower()).first()
if not user or not verify_password(body.password, user.password_hash):
raise HTTPException(401, "Invalid credentials")
event_bus.publish("user.logged_in", {"user_id": user.id}, db=db)
return TokenOut(
access_token=make_access_token(user.id, user.role),
refresh_token=make_refresh_token(user.id, user.role),
role=user.role,
user_id=user.id,
)
class RefreshIn(BaseModel):
refresh_token: str
@router.post("/refresh", response_model=TokenOut)
def refresh(body: RefreshIn, db: Session = Depends(get_db)):
claims = decode_token(body.refresh_token)
if claims.get("type") != "refresh":
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Not a refresh token")
user_id = int(claims["sub"])
user = db.get(User, user_id)
if not user:
raise HTTPException(401, "User gone")
return TokenOut(
access_token=make_access_token(user.id, user.role),
refresh_token=make_refresh_token(user.id, user.role),
role=user.role,
user_id=user.id,
)
@router.get("/me", response_model=UserOut)
def me(user_id: int = Depends(get_current_user_id), db: Session = Depends(get_db)):
user = db.get(User, user_id)
if not user:
raise HTTPException(404, "User not found")
return UserOut(id=user.id, email=user.email, name=user.name, role=user.role, locale=user.locale)
@router.put("/me", response_model=UserOut)
def update_me(
body: UpdateMeIn,
user_id: int = Depends(get_current_user_id),
db: Session = Depends(get_db),
):
user = db.get(User, user_id)
if not user:
raise HTTPException(404, "User not found")
if body.name is not None:
user.name = body.name
if body.locale is not None:
user.locale = body.locale
db.commit()
return UserOut(id=user.id, email=user.email, name=user.name, role=user.role, locale=user.locale)
@router.post("/change-password")
def change_password(
body: ChangePasswordIn,
user_id: int = Depends(get_current_user_id),
db: Session = Depends(get_db),
):
user = db.get(User, user_id)
if not user or not verify_password(body.old_password, user.password_hash):
raise HTTPException(400, "Old password wrong")
user.password_hash = hash_password(body.new_password)
db.commit()
return {"ok": True}

View File

@@ -0,0 +1,6 @@
name: auth
version: 0.1.0
depends_on: [core]
conflicts_with: []
required: true
provides: [UserService]

View File

@@ -0,0 +1,18 @@
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from core.db import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255))
role: Mapped[str] = mapped_column(String(32), default="customer") # 'customer' | 'admin'
name: Mapped[str] = mapped_column(String(128), default="")
locale: Mapped[str] = mapped_column(String(8), default="de")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View File

@@ -0,0 +1,140 @@
import json
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from core.db import get_db
from core.redis_client import redis_client
from core.security import get_current_user_id
from .models import Cart, CartItem
router = APIRouter()
class AddItemIn(BaseModel):
product_id: int
qty: int = 1
class UpdateItemIn(BaseModel):
qty: int
class CartItemOut(BaseModel):
product_id: int
qty: int
name: dict = {}
price: float = 0.0
image_url: str = ""
line_total: float = 0.0
class CartOut(BaseModel):
items: list[CartItemOut]
subtotal: float
def _get_or_create_cart(db: Session, user_id: int) -> Cart:
cart = db.query(Cart).filter_by(user_id=user_id).first()
if not cart:
cart = Cart(user_id=user_id)
db.add(cart)
db.commit()
db.refresh(cart)
return cart
def _cart_to_out(cart: Cart) -> CartOut:
items: list[CartItemOut] = []
subtotal = 0.0
for it in cart.items:
raw = redis_client.get(f"product:{it.product_id}")
if not raw:
continue
p = json.loads(raw)
line = round(float(p["price"]) * it.qty, 2)
subtotal += line
items.append(
CartItemOut(
product_id=it.product_id,
qty=it.qty,
name=p.get("name", {}),
price=float(p["price"]),
image_url=p.get("image_url", ""),
line_total=line,
)
)
return CartOut(items=items, subtotal=round(subtotal, 2))
@router.get("", response_model=CartOut)
def get_cart(user_id: int = Depends(get_current_user_id), db: Session = Depends(get_db)):
return _cart_to_out(_get_or_create_cart(db, user_id))
@router.post("/items", response_model=CartOut)
def add_item(
body: AddItemIn,
user_id: int = Depends(get_current_user_id),
db: Session = Depends(get_db),
):
if body.qty < 1:
raise HTTPException(400, "qty must be >= 1")
if not redis_client.get(f"product:{body.product_id}"):
raise HTTPException(404, "Product not found or inactive")
cart = _get_or_create_cart(db, user_id)
existing = db.query(CartItem).filter_by(cart_id=cart.id, product_id=body.product_id).first()
if existing:
existing.qty += body.qty
else:
db.add(CartItem(cart_id=cart.id, product_id=body.product_id, qty=body.qty))
db.commit()
db.refresh(cart)
return _cart_to_out(cart)
@router.put("/items/{product_id}", response_model=CartOut)
def update_item(
product_id: int,
body: UpdateItemIn,
user_id: int = Depends(get_current_user_id),
db: Session = Depends(get_db),
):
cart = _get_or_create_cart(db, user_id)
item = db.query(CartItem).filter_by(cart_id=cart.id, product_id=product_id).first()
if not item:
raise HTTPException(404, "Not in cart")
if body.qty < 1:
db.delete(item)
else:
item.qty = body.qty
db.commit()
db.refresh(cart)
return _cart_to_out(cart)
@router.delete("/items/{product_id}", response_model=CartOut)
def remove_item(
product_id: int,
user_id: int = Depends(get_current_user_id),
db: Session = Depends(get_db),
):
cart = _get_or_create_cart(db, user_id)
item = db.query(CartItem).filter_by(cart_id=cart.id, product_id=product_id).first()
if item:
db.delete(item)
db.commit()
db.refresh(cart)
return _cart_to_out(cart)
@router.delete("", response_model=CartOut)
def clear_cart(user_id: int = Depends(get_current_user_id), db: Session = Depends(get_db)):
cart = _get_or_create_cart(db, user_id)
for it in list(cart.items):
db.delete(it)
db.commit()
db.refresh(cart)
return _cart_to_out(cart)

View File

@@ -0,0 +1,6 @@
name: cart
version: 0.1.0
depends_on: [core, auth, catalog]
conflicts_with: []
required: true
provides: [CartService]

View File

@@ -0,0 +1,30 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from core.db import Base
class Cart(Base):
__tablename__ = "carts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), unique=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
items: Mapped[list["CartItem"]] = relationship(
"CartItem", back_populates="cart", cascade="all, delete-orphan", lazy="joined"
)
class CartItem(Base):
__tablename__ = "cart_items"
__table_args__ = (UniqueConstraint("cart_id", "product_id", name="uq_cart_product"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
cart_id: Mapped[int] = mapped_column(ForeignKey("carts.id", ondelete="CASCADE"))
product_id: Mapped[int] = mapped_column(ForeignKey("products.id", ondelete="CASCADE"))
qty: Mapped[int] = mapped_column(Integer, default=1)
cart: Mapped["Cart"] = relationship("Cart", back_populates="items")

View File

@@ -0,0 +1,242 @@
import json
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from core.config import settings
from core.db import get_db
from core.events import event_bus
from core.redis_client import redis_client
from core.security import require_admin
from .models import Category, Product
from .projector import (
delete_category_from_cache,
delete_product_from_cache,
project_category,
project_product,
rebuild_all,
)
router = APIRouter()
class CategoryIn(BaseModel):
slug: str
name: dict = Field(default_factory=dict)
parent_id: int | None = None
sort_order: int = 0
class CategoryOut(CategoryIn):
id: int
class ProductIn(BaseModel):
sku: str
name: dict = Field(default_factory=dict)
description: dict = Field(default_factory=dict)
price: float
currency: str = "EUR"
stock: int = 0
active: bool = True
image_url: str = ""
category_id: int | None = None
attributes: dict = Field(default_factory=dict)
class ProductOut(ProductIn):
id: int
# ------- Public reads (Redis-backed) --------
@router.get("/products")
def list_products():
raw = redis_client.get("product:list")
ids: list[int] = json.loads(raw) if raw else []
if not ids:
return []
keys = [f"product:{i}" for i in ids]
values = redis_client.mget(keys)
return [json.loads(v) for v in values if v]
@router.get("/products/{product_id}")
def read_product(product_id: int):
raw = redis_client.get(f"product:{product_id}")
if not raw:
raise HTTPException(404, "Product not found")
return json.loads(raw)
@router.get("/categories")
def list_categories():
raw = redis_client.get("category:tree")
return json.loads(raw) if raw else []
@router.get("/categories/{category_id}/products")
def products_by_category(category_id: int):
# Linear scan acceptable for prototype
raw = redis_client.get("product:list")
ids: list[int] = json.loads(raw) if raw else []
if not ids:
return []
values = redis_client.mget([f"product:{i}" for i in ids])
out = []
for v in values:
if not v:
continue
d = json.loads(v)
if d.get("category_id") == category_id:
out.append(d)
return out
# ------- Admin writes --------
admin_router = APIRouter(dependencies=[Depends(require_admin)])
@admin_router.get("/products", response_model=list[ProductOut])
def admin_list_products(db: Session = Depends(get_db)):
rows = db.query(Product).order_by(Product.id.desc()).all()
return [_to_product_out(p) for p in rows]
@admin_router.post("/products", response_model=ProductOut)
def admin_create_product(body: ProductIn, db: Session = Depends(get_db)):
if db.query(Product).filter_by(sku=body.sku).first():
raise HTTPException(400, "SKU already exists")
p = Product(**body.model_dump())
db.add(p)
db.commit()
db.refresh(p)
project_product(db, p.id)
event_bus.publish("product.created", {"id": p.id, "sku": p.sku}, db=db)
return _to_product_out(p)
@admin_router.put("/products/{pid}", response_model=ProductOut)
def admin_update_product(pid: int, body: ProductIn, db: Session = Depends(get_db)):
p = db.get(Product, pid)
if not p:
raise HTTPException(404, "Not found")
for k, v in body.model_dump().items():
setattr(p, k, v)
db.commit()
db.refresh(p)
project_product(db, p.id)
event_bus.publish("product.updated", {"id": p.id}, db=db)
return _to_product_out(p)
@admin_router.delete("/products/{pid}")
def admin_delete_product(pid: int, db: Session = Depends(get_db)):
p = db.get(Product, pid)
if not p:
raise HTTPException(404, "Not found")
db.delete(p)
db.commit()
delete_product_from_cache(pid, db)
event_bus.publish("product.deleted", {"id": pid}, db=db)
return {"ok": True}
@admin_router.get("/categories", response_model=list[CategoryOut])
def admin_list_categories(db: Session = Depends(get_db)):
rows = db.query(Category).order_by(Category.sort_order, Category.id).all()
return [_to_category_out(c) for c in rows]
@admin_router.post("/categories", response_model=CategoryOut)
def admin_create_category(body: CategoryIn, db: Session = Depends(get_db)):
if db.query(Category).filter_by(slug=body.slug).first():
raise HTTPException(400, "Slug already exists")
c = Category(**body.model_dump())
db.add(c)
db.commit()
db.refresh(c)
project_category(db, c.id)
event_bus.publish("category.created", {"id": c.id}, db=db)
return _to_category_out(c)
@admin_router.put("/categories/{cid}", response_model=CategoryOut)
def admin_update_category(cid: int, body: CategoryIn, db: Session = Depends(get_db)):
c = db.get(Category, cid)
if not c:
raise HTTPException(404, "Not found")
for k, v in body.model_dump().items():
setattr(c, k, v)
db.commit()
db.refresh(c)
project_category(db, c.id)
event_bus.publish("category.updated", {"id": c.id}, db=db)
return _to_category_out(c)
@admin_router.delete("/categories/{cid}")
def admin_delete_category(cid: int, db: Session = Depends(get_db)):
c = db.get(Category, cid)
if not c:
raise HTTPException(404, "Not found")
db.delete(c)
db.commit()
delete_category_from_cache(cid, db)
event_bus.publish("category.deleted", {"id": cid}, db=db)
return {"ok": True}
@admin_router.post("/upload")
async def upload_image(file: UploadFile = File(...)):
ext = Path(file.filename or "").suffix or ".bin"
name = f"{uuid.uuid4().hex}{ext}"
dest = Path(settings.UPLOAD_DIR) / name
dest.parent.mkdir(parents=True, exist_ok=True)
content = await file.read()
dest.write_bytes(content)
return {"url": f"{settings.PUBLIC_BASE_URL}/uploads/{name}"}
@admin_router.post("/rebuild-cache")
def admin_rebuild_cache(db: Session = Depends(get_db)):
rebuild_all(db)
return {"ok": True}
router.include_router(admin_router, prefix="/admin")
# ------- helpers -------
def _to_product_out(p: Product) -> ProductOut:
return ProductOut(
id=p.id,
sku=p.sku,
name=p.name or {},
description=p.description or {},
price=float(p.price),
currency=p.currency,
stock=p.stock,
active=p.active,
image_url=p.image_url,
category_id=p.category_id,
attributes=p.attributes or {},
)
def _to_category_out(c: Category) -> CategoryOut:
return CategoryOut(
id=c.id,
slug=c.slug,
name=c.name or {},
parent_id=c.parent_id,
sort_order=c.sort_order,
)

View File

@@ -0,0 +1,6 @@
name: catalog
version: 0.1.0
depends_on: [core, auth]
conflicts_with: []
required: true
provides: [ProductService, CategoryService]

View File

@@ -0,0 +1,39 @@
from datetime import datetime
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, Numeric, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from core.db import Base
class Category(Base):
__tablename__ = "categories"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
slug: Mapped[str] = mapped_column(String(128), unique=True, index=True)
name: Mapped[dict] = mapped_column(JSON, default=dict) # {'de': '...', 'en': '...'}
parent_id: Mapped[int | None] = mapped_column(
ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
class Product(Base):
__tablename__ = "products"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
sku: Mapped[str] = mapped_column(String(64), unique=True, index=True)
name: Mapped[dict] = mapped_column(JSON, default=dict) # i18n
description: Mapped[dict] = mapped_column(JSON, default=dict) # i18n
price: Mapped[float] = mapped_column(Numeric(10, 2))
currency: Mapped[str] = mapped_column(String(3), default="EUR")
stock: Mapped[int] = mapped_column(Integer, default=0)
active: Mapped[bool] = mapped_column(Boolean, default=True)
image_url: Mapped[str] = mapped_column(String(500), default="")
category_id: Mapped[int | None] = mapped_column(
ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
)
attributes: Mapped[dict] = mapped_column(JSON, default=dict) # color, size, ...
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
category: Mapped[Category | None] = relationship("Category", lazy="joined")

View File

@@ -0,0 +1,87 @@
"""Projects catalog changes into Redis for the Shop frontend to read."""
from __future__ import annotations
import json
from sqlalchemy.orm import Session
from core.redis_client import redis_client
from .models import Category, Product
def _product_to_dict(p: Product) -> dict:
return {
"id": p.id,
"sku": p.sku,
"name": p.name,
"description": p.description,
"price": float(p.price),
"currency": p.currency,
"stock": p.stock,
"active": p.active,
"image_url": p.image_url,
"category_id": p.category_id,
"attributes": p.attributes or {},
}
def _category_to_dict(c: Category) -> dict:
return {
"id": c.id,
"slug": c.slug,
"name": c.name,
"parent_id": c.parent_id,
"sort_order": c.sort_order,
}
def project_product(db: Session, product_id: int) -> None:
p = db.get(Product, product_id)
if not p or not p.active:
redis_client.delete(f"product:{product_id}")
_refresh_product_list(db)
return
redis_client.set(f"product:{product_id}", json.dumps(_product_to_dict(p)))
_refresh_product_list(db)
def delete_product_from_cache(product_id: int, db: Session) -> None:
redis_client.delete(f"product:{product_id}")
_refresh_product_list(db)
def _refresh_product_list(db: Session) -> None:
ids = [row[0] for row in db.query(Product.id).filter(Product.active.is_(True)).all()]
redis_client.set("product:list", json.dumps(ids))
def project_category(db: Session, category_id: int) -> None:
c = db.get(Category, category_id)
if not c:
redis_client.delete(f"category:{category_id}")
else:
redis_client.set(f"category:{category_id}", json.dumps(_category_to_dict(c)))
_refresh_category_tree(db)
def delete_category_from_cache(category_id: int, db: Session) -> None:
redis_client.delete(f"category:{category_id}")
_refresh_category_tree(db)
def _refresh_category_tree(db: Session) -> None:
cats = db.query(Category).order_by(Category.sort_order, Category.id).all()
data = [_category_to_dict(c) for c in cats]
redis_client.set("category:tree", json.dumps(data))
def rebuild_all(db: Session) -> None:
# Refresh every product/category key
for p in db.query(Product).all():
if p.active:
redis_client.set(f"product:{p.id}", json.dumps(_product_to_dict(p)))
for c in db.query(Category).all():
redis_client.set(f"category:{c.id}", json.dumps(_category_to_dict(c)))
_refresh_product_list(db)
_refresh_category_tree(db)

View File

@@ -0,0 +1,106 @@
import json
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from core.db import get_db
from core.di import get_service
from core.events import event_bus
from core.redis_client import redis_client
from core.security import get_current_user_id
from apps.cart.models import Cart, CartItem
router = APIRouter()
class AddressIn(BaseModel):
name: str
street: str
zip: str
city: str
country: str = "DE"
class CheckoutIn(BaseModel):
address: AddressIn
payment_method: str = "dummy"
class CheckoutPreview(BaseModel):
items: list[dict]
subtotal: float
total: float
@router.post("/preview", response_model=CheckoutPreview)
def preview(user_id: int = Depends(get_current_user_id), db: Session = Depends(get_db)):
cart = db.query(Cart).filter_by(user_id=user_id).first()
if not cart or not cart.items:
raise HTTPException(400, "Cart is empty")
items = []
subtotal = 0.0
for it in cart.items:
raw = redis_client.get(f"product:{it.product_id}")
if not raw:
raise HTTPException(400, f"Product {it.product_id} no longer available")
p = json.loads(raw)
line = round(float(p["price"]) * it.qty, 2)
subtotal += line
items.append({"product_id": it.product_id, "name": p["name"], "qty": it.qty, "price": float(p["price"]), "line_total": line})
return CheckoutPreview(items=items, subtotal=round(subtotal, 2), total=round(subtotal, 2))
@router.post("/confirm")
def confirm(body: CheckoutIn, user_id: int = Depends(get_current_user_id), db: Session = Depends(get_db)):
cart = db.query(Cart).filter_by(user_id=user_id).first()
if not cart or not cart.items:
raise HTTPException(400, "Cart is empty")
# Snapshot items
snapshot_items = []
subtotal = 0.0
for it in cart.items:
raw = redis_client.get(f"product:{it.product_id}")
if not raw:
raise HTTPException(400, f"Product {it.product_id} not available")
p = json.loads(raw)
line = round(float(p["price"]) * it.qty, 2)
subtotal += line
snapshot_items.append(
{
"product_id": it.product_id,
"sku": p.get("sku", ""),
"name": p.get("name", {}),
"price": float(p["price"]),
"qty": it.qty,
"line_total": line,
}
)
total = round(subtotal, 2)
payment = get_service("PaymentProvider").charge(total, "EUR", body.payment_method)
if payment["status"] != "paid":
raise HTTPException(402, "Payment failed")
event_payload = {
"user_id": user_id,
"items": snapshot_items,
"subtotal": round(subtotal, 2),
"total": total,
"currency": "EUR",
"address": body.address.model_dump(),
"payment": payment,
}
event_bus.publish("checkout.confirmed", event_payload, db=db)
# Clear cart
for it in list(cart.items):
db.delete(it)
db.commit()
# Find order id from events handler (orders-app) — pull via redis key or return event_payload
# Since the orders handler sets 'last_order_id' for this user in redis for convenience:
last = redis_client.get(f"user:{user_id}:last_order_id")
return {"ok": True, "order_id": int(last) if last else None, "payment": payment}

View File

@@ -0,0 +1,6 @@
name: checkout
version: 0.1.0
depends_on: [core, auth, catalog, cart, payment]
conflicts_with: []
required: true
provides: []

View File

@@ -0,0 +1,8 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/ping")
def ping():
return {"msg": "hello from dummy app"}

View File

@@ -0,0 +1,6 @@
name: hello
version: 0.1.0
depends_on: [core]
conflicts_with: []
required: false
provides: []

View File

@@ -0,0 +1,51 @@
"""Mail sender via SMTP (Mailhog in dev)."""
from __future__ import annotations
import asyncio
from email.message import EmailMessage
import aiosmtplib
from fastapi import APIRouter
from core.config import settings
from core.di import register_service
router = APIRouter()
class MailService:
async def send(self, to: str, subject: str, body_html: str, body_text: str = "") -> None:
msg = EmailMessage()
msg["From"] = settings.MAIL_FROM
msg["To"] = to
msg["Subject"] = subject
if body_text:
msg.set_content(body_text)
msg.add_alternative(body_html, subtype="html")
else:
msg.set_content(body_html, subtype="html")
try:
await aiosmtplib.send(
msg,
hostname=settings.SMTP_HOST,
port=settings.SMTP_PORT,
start_tls=False,
)
except Exception as e: # noqa: BLE001
print(f"[mail] send error: {e}")
def send_sync(self, to: str, subject: str, body_html: str, body_text: str = "") -> None:
"""Blocking wrapper for event handlers."""
try:
asyncio.run(self.send(to, subject, body_html, body_text))
except RuntimeError:
# inside running loop (unlikely in sync event handlers, but just in case)
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(self.send(to, subject, body_html, body_text))
finally:
loop.close()
def on_load() -> None:
register_service("MailService", MailService())

View File

@@ -0,0 +1,6 @@
name: mail
version: 0.1.0
depends_on: [core]
conflicts_with: []
required: true
provides: [MailService]

View File

@@ -0,0 +1,175 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from jinja2 import Template
from pydantic import BaseModel
from sqlalchemy.orm import Session
from core.db import get_db
from core.di import get_service
from core.events import event_bus
from core.redis_client import redis_client
from core.security import get_current_user_id, require_admin
from core.settings_service import get_setting_cached
from apps.auth.models import User
from .models import Order, OrderStatusHistory
router = APIRouter()
_EMAIL_TEMPLATE_DE = """
<h1>Bestellbestätigung #{{ order.id }}</h1>
<p>Vielen Dank für deine Bestellung bei {{ shop_name }}!</p>
<h2>Artikel</h2>
<ul>
{% for it in order['items'] %}
<li>{{ it['name'].get('de', it['name'].get('en', it.get('sku',''))) }} — {{ it['qty'] }} × {{ '%.2f' % it['price'] }} {{ order['currency'] }}</li>
{% endfor %}
</ul>
<p><strong>Gesamt: {{ '%.2f' % order['total'] }} {{ order['currency'] }}</strong></p>
<p>Lieferadresse: {{ order['address']['name'] }}, {{ order['address']['street'] }}, {{ order['address']['zip'] }} {{ order['address']['city'] }}</p>
<p>Zahlungsreferenz: {{ order['payment']['transaction_id'] }}</p>
"""
def _render_mail(order_dict: dict, shop_name: str) -> str:
return Template(_EMAIL_TEMPLATE_DE).render(order=order_dict, shop_name=shop_name)
# Event handler for checkout.confirmed ------------------------------------
def _on_checkout_confirmed(event_type: str, payload: dict[str, Any], db: Session) -> None:
order = Order(
user_id=payload.get("user_id"),
status="paid",
total=payload["total"],
currency=payload.get("currency", "EUR"),
address=payload.get("address", {}),
payment=payload.get("payment", {}),
items=payload.get("items", []),
)
db.add(order)
db.flush()
db.add(OrderStatusHistory(order_id=order.id, status="paid", note="auto"))
db.commit()
# Convenience key for the synchronous checkout handler to return order_id
if payload.get("user_id"):
redis_client.set(f"user:{payload['user_id']}:last_order_id", str(order.id), ex=60)
# Send confirmation mail
user = db.get(User, order.user_id) if order.user_id else None
if user:
shop_name = get_setting_cached("core.shop_name", "Shop")
body = _render_mail(
{
"id": order.id,
"items": order.items,
"total": float(order.total),
"currency": order.currency,
"address": order.address,
"payment": order.payment,
},
shop_name,
)
try:
mail = get_service("MailService")
mail.send_sync(user.email, f"Bestellbestätigung #{order.id}", body)
except Exception as e: # noqa: BLE001
print(f"[orders] mail send failed: {e}")
event_bus.publish("order.created", {"id": order.id, "user_id": order.user_id}, db=db)
def on_load() -> None:
event_bus.subscribe("checkout.confirmed", _on_checkout_confirmed)
# API -------------------------------------------------------------------
class OrderOut(BaseModel):
id: int
user_id: int | None
status: str
total: float
currency: str
address: dict
payment: dict
items: list
created_at: str
class StatusUpdateIn(BaseModel):
status: str
note: str = ""
def _to_out(o: Order) -> OrderOut:
return OrderOut(
id=o.id,
user_id=o.user_id,
status=o.status,
total=float(o.total),
currency=o.currency,
address=o.address or {},
payment=o.payment or {},
items=o.items or [],
created_at=o.created_at.isoformat() if o.created_at else "",
)
# Admin sub-router is registered FIRST so /admin* doesn't get shadowed by /{order_id}
admin_router = APIRouter(dependencies=[Depends(require_admin)])
@admin_router.get("", response_model=list[OrderOut])
def admin_list_orders(db: Session = Depends(get_db)):
rows = db.query(Order).order_by(Order.created_at.desc()).all()
return [_to_out(o) for o in rows]
@admin_router.get("/{order_id}", response_model=OrderOut)
def admin_read_order(order_id: int, db: Session = Depends(get_db)):
o = db.get(Order, order_id)
if not o:
raise HTTPException(404, "Not found")
return _to_out(o)
@admin_router.put("/{order_id}/status", response_model=OrderOut)
def admin_update_status(order_id: int, body: StatusUpdateIn, db: Session = Depends(get_db)):
o = db.get(Order, order_id)
if not o:
raise HTTPException(404, "Not found")
o.status = body.status
db.add(OrderStatusHistory(order_id=order_id, status=body.status, note=body.note))
db.commit()
db.refresh(o)
event_bus.publish("order.status_changed", {"id": order_id, "status": body.status}, db=db)
return _to_out(o)
router.include_router(admin_router, prefix="/admin")
# Customer routes — defined AFTER admin include so '/admin' matches first
@router.get("", response_model=list[OrderOut])
def list_my_orders(user_id: int = Depends(get_current_user_id), db: Session = Depends(get_db)):
rows = (
db.query(Order)
.filter(Order.user_id == user_id)
.order_by(Order.created_at.desc())
.all()
)
return [_to_out(o) for o in rows]
@router.get("/{order_id}", response_model=OrderOut)
def read_order(order_id: int, user_id: int = Depends(get_current_user_id), db: Session = Depends(get_db)):
o = db.get(Order, order_id)
if not o or o.user_id != user_id:
raise HTTPException(404, "Order not found")
return _to_out(o)

View File

@@ -0,0 +1,6 @@
name: orders
version: 0.1.0
depends_on: [core, auth, catalog, mail]
conflicts_with: []
required: true
provides: [OrderService]

View File

@@ -0,0 +1,34 @@
from datetime import datetime
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, Numeric, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from core.db import Base
class Order(Base):
__tablename__ = "orders"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
status: Mapped[str] = mapped_column(String(32), default="paid")
total: Mapped[float] = mapped_column(Numeric(10, 2))
currency: Mapped[str] = mapped_column(String(3), default="EUR")
address: Mapped[dict] = mapped_column(JSON, default=dict)
payment: Mapped[dict] = mapped_column(JSON, default=dict)
items: Mapped[list[dict]] = mapped_column(JSON, default=list)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
status_history: Mapped[list["OrderStatusHistory"]] = relationship(
"OrderStatusHistory", cascade="all, delete-orphan", lazy="joined"
)
class OrderStatusHistory(Base):
__tablename__ = "order_status_history"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
order_id: Mapped[int] = mapped_column(ForeignKey("orders.id", ondelete="CASCADE"))
status: Mapped[str] = mapped_column(String(32))
note: Mapped[str] = mapped_column(String(500), default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View File

@@ -0,0 +1,34 @@
"""Dummy payment provider — always approves."""
from __future__ import annotations
import uuid
from fastapi import APIRouter
from core.di import register_service
router = APIRouter()
class PaymentProvider:
name = "DummyPayment"
def charge(self, amount: float, currency: str, method: str = "dummy") -> dict:
return {
"status": "paid",
"transaction_id": f"DUM-{uuid.uuid4().hex[:12]}",
"amount": amount,
"currency": currency,
"method": method,
}
def on_load() -> None:
register_service("PaymentProvider", PaymentProvider())
@router.get("/methods")
def available_methods():
return [
{"id": "dummy", "label_de": "Testzahlung", "label_en": "Test payment"},
]

View File

@@ -0,0 +1,6 @@
name: payment
version: 0.1.0
depends_on: [core]
conflicts_with: []
required: true
provides: [PaymentProvider]

0
backend/core/__init__.py Normal file
View File

37
backend/core/config.py Normal file
View File

@@ -0,0 +1,37 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
APP_ENV: str = "dev"
DATABASE_URL: str = "postgresql+psycopg://shop:shop@localhost:5432/shop"
REDIS_URL: str = "redis://localhost:6379/0"
MEILI_URL: str = "http://localhost:7700"
MEILI_KEY: str = "shop-dev-master-key"
OLLAMA_URL: str = "http://localhost:11434"
OLLAMA_CHAT_MODEL: str = "llama3.1"
OLLAMA_EMBED_MODEL: str = "nomic-embed-text"
OLLAMA_EMBED_DIM: int = 768
SMTP_HOST: str = "localhost"
SMTP_PORT: int = 1025
MAIL_FROM: str = "shop@example.com"
JWT_SECRET: str = "change-me"
JWT_ACCESS_MINUTES: int = 15
JWT_REFRESH_DAYS: int = 30
UPLOAD_DIR: str = "./uploads"
PUBLIC_BASE_URL: str = "http://localhost:8000"
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:5174"
@property
def cors_list(self) -> list[str]:
return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()]
settings = Settings()

21
backend/core/db.py Normal file
View File

@@ -0,0 +1,21 @@
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from core.config import settings
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True, future=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
class Base(DeclarativeBase):
pass
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()

18
backend/core/di.py Normal file
View File

@@ -0,0 +1,18 @@
"""Minimal service registry. Apps call `register_service(name, instance)` at startup."""
from typing import Any
_services: dict[str, Any] = {}
def register_service(name: str, instance: Any) -> None:
_services[name] = instance
def get_service(name: str) -> Any:
if name not in _services:
raise KeyError(f"Service not registered: {name}")
return _services[name]
def has_service(name: str) -> bool:
return name in _services

62
backend/core/events.py Normal file
View File

@@ -0,0 +1,62 @@
from __future__ import annotations
from collections import defaultdict
from collections.abc import Callable
from datetime import datetime
from typing import Any
from sqlalchemy import JSON, DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, Session, mapped_column
from core.db import Base, SessionLocal
EventHandler = Callable[[str, dict[str, Any], Session], None]
class EventLog(Base):
__tablename__ = "events"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
type: Mapped[str] = mapped_column(String(128), index=True)
payload: Mapped[dict] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class EventBus:
def __init__(self) -> None:
self._handlers: dict[str, list[EventHandler]] = defaultdict(list)
self._wildcards: list[tuple[str, EventHandler]] = []
def subscribe(self, event_type: str, handler: EventHandler) -> None:
"""Subscribe to an event type. Supports 'namespace.*' wildcards."""
if event_type.endswith(".*"):
self._wildcards.append((event_type[:-2], handler))
else:
self._handlers[event_type].append(handler)
def publish(self, event_type: str, payload: dict[str, Any], db: Session | None = None) -> None:
"""Publish event. Persists to events table and calls all handlers synchronously."""
own_db = db is None
if own_db:
db = SessionLocal()
try:
db.add(EventLog(type=event_type, payload=payload))
db.commit()
for h in self._handlers.get(event_type, []):
try:
h(event_type, payload, db)
except Exception as e: # noqa: BLE001
print(f"[event-handler-error] {event_type}: {e}")
for prefix, h in self._wildcards:
if event_type.startswith(prefix + ".") or event_type == prefix:
try:
h(event_type, payload, db)
except Exception as e: # noqa: BLE001
print(f"[event-handler-error] {event_type}: {e}")
finally:
if own_db:
db.close()
event_bus = EventBus()

16
backend/core/i18n.py Normal file
View File

@@ -0,0 +1,16 @@
"""Tiny i18n helper for DE/EN content stored as {'de': ..., 'en': ...} dicts."""
from typing import Any
DEFAULT_LOCALE = "de"
SUPPORTED = ("de", "en")
def pick(field: dict[str, Any] | None, locale: str = DEFAULT_LOCALE) -> str:
if not field:
return ""
return field.get(locale) or field.get(DEFAULT_LOCALE) or next(iter(field.values()), "")
def normalize(field: dict[str, Any] | None) -> dict[str, str]:
field = field or {}
return {lang: str(field.get(lang, "")) for lang in SUPPORTED}

99
backend/core/loader.py Normal file
View File

@@ -0,0 +1,99 @@
"""App-Loader: discovers apps/<name>/manifest.yaml, resolves deps, imports and registers."""
from __future__ import annotations
import importlib
from dataclasses import dataclass, field
from pathlib import Path
import yaml
from fastapi import APIRouter, FastAPI
APPS_DIR = Path(__file__).resolve().parent.parent / "apps"
@dataclass
class AppManifest:
name: str
version: str = "0.1.0"
depends_on: list[str] = field(default_factory=list)
conflicts_with: list[str] = field(default_factory=list)
required: bool = False
provides: list[str] = field(default_factory=list)
def discover_manifests() -> list[AppManifest]:
manifests: list[AppManifest] = []
if not APPS_DIR.exists():
return manifests
for entry in sorted(APPS_DIR.iterdir()):
mf_path = entry / "manifest.yaml"
if not mf_path.exists():
continue
data = yaml.safe_load(mf_path.read_text()) or {}
manifests.append(
AppManifest(
name=data["name"],
version=data.get("version", "0.1.0"),
depends_on=list(data.get("depends_on", [])),
conflicts_with=list(data.get("conflicts_with", [])),
required=bool(data.get("required", False)),
provides=list(data.get("provides", [])),
)
)
return manifests
def _topo_sort(manifests: list[AppManifest]) -> list[AppManifest]:
by_name = {m.name: m for m in manifests}
visited: set[str] = set()
temp: set[str] = set()
order: list[AppManifest] = []
def visit(name: str) -> None:
if name in visited:
return
if name in temp:
raise RuntimeError(f"Circular app dependency at {name}")
m = by_name.get(name)
if not m:
return # dependency on core or non-app (e.g. 'core')
temp.add(name)
for d in m.depends_on:
if d == "core":
continue
if d not in by_name:
raise RuntimeError(f"App {name} depends on missing app: {d}")
visit(d)
temp.discard(name)
visited.add(name)
order.append(m)
for m in manifests:
visit(m.name)
return order
def _check_conflicts(manifests: list[AppManifest]) -> None:
names = {m.name for m in manifests}
for m in manifests:
for c in m.conflicts_with:
if c in names:
raise RuntimeError(f"App conflict: {m.name} conflicts with {c}")
def load_apps(app: FastAPI) -> list[AppManifest]:
manifests = discover_manifests()
_check_conflicts(manifests)
ordered = _topo_sort(manifests)
loaded: list[AppManifest] = []
for m in ordered:
module_name = f"apps.{m.name}"
mod = importlib.import_module(module_name)
# Optional lifecycle hook
if hasattr(mod, "on_load") and callable(mod.on_load):
mod.on_load()
if hasattr(mod, "router") and isinstance(mod.router, APIRouter):
app.include_router(mod.router, prefix=f"/api/{m.name}", tags=[m.name])
loaded.append(m)
print(f"[app-loader] loaded {m.name} v{m.version}")
return loaded

89
backend/core/main.py Normal file
View File

@@ -0,0 +1,89 @@
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import APIRouter, Body, Depends, FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from sqlalchemy import text
from sqlalchemy.orm import Session
from core.config import settings
from core.db import get_db
from core.loader import load_apps
from core.redis_client import redis_client
from core.security import require_admin
from core.settings_service import get_setting_cached, set_setting
@asynccontextmanager
async def lifespan(app: FastAPI):
loaded = load_apps(app)
app.state.loaded_apps = loaded
yield
app = FastAPI(title="Shopsystem API", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Static uploads
upload_dir = Path(settings.UPLOAD_DIR)
upload_dir.mkdir(parents=True, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=str(upload_dir)), name="uploads")
@app.get("/health")
def health(db: Session = Depends(get_db)):
db_ok = False
redis_ok = False
try:
db.execute(text("SELECT 1"))
db_ok = True
except Exception:
pass
try:
redis_ok = bool(redis_client.ping())
except Exception:
pass
return {
"ok": db_ok and redis_ok,
"env": settings.APP_ENV,
"db": db_ok,
"redis": redis_ok,
"apps": [m.name for m in getattr(app.state, "loaded_apps", [])],
}
# Core router — settings management (admin)
core_router = APIRouter()
@core_router.get("/settings/{key}")
def read_setting(key: str):
"""Public read — Redis-backed."""
v = get_setting_cached(key)
if v is None:
raise HTTPException(404, f"Setting {key} not found")
return {"key": key, "value": v}
@core_router.put("/settings/{key}")
def write_setting(
key: str,
body: dict = Body(...),
_: dict = Depends(require_admin),
db: Session = Depends(get_db),
):
if "value" not in body:
raise HTTPException(400, "Missing 'value' in body")
set_setting(db, key, body["value"])
return {"key": key, "value": body["value"]}
app.include_router(core_router, prefix="/api/core", tags=["core"])

View File

@@ -0,0 +1,63 @@
from logging.config import fileConfig
from pathlib import Path
from alembic import context
from sqlalchemy import engine_from_config, pool
from core.config import settings
from core.db import Base
# Import all model modules so Base.metadata contains everything ----
# Core
from core import events # noqa: F401
from core import settings_service # noqa: F401
# Apps — import each app's models if present
import importlib
APPS_DIR = Path(__file__).resolve().parent.parent.parent / "apps"
if APPS_DIR.exists():
for entry in sorted(APPS_DIR.iterdir()):
models_py = entry / "models.py"
if models_py.exists():
importlib.import_module(f"apps.{entry.name}.models")
# -------------------------------------------------------------------
config = context.config
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,47 @@
"""core init: events + settings + pgvector
Revision ID: 0001_core
Revises:
Create Date: 2026-04-16
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0001_core"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# pgvector extension (used by ai_core later, set up early for cleanliness)
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
op.create_table(
"events",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("type", sa.String(128), nullable=False),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
)
op.create_index("ix_events_type", "events", ["type"])
op.create_table(
"settings",
sa.Column("key", sa.String(128), primary_key=True),
sa.Column("value", sa.JSON(), nullable=False),
)
def downgrade() -> None:
op.drop_table("settings")
op.drop_index("ix_events_type", table_name="events")
op.drop_table("events")

View File

@@ -0,0 +1,40 @@
"""auth: users
Revision ID: 0002_auth
Revises: 0001_core
Create Date: 2026-04-16
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0002_auth"
down_revision: str | None = "0001_core"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("email", sa.String(255), nullable=False, unique=True),
sa.Column("password_hash", sa.String(255), nullable=False),
sa.Column("role", sa.String(32), nullable=False, server_default="customer"),
sa.Column("name", sa.String(128), nullable=False, server_default=""),
sa.Column("locale", sa.String(8), nullable=False, server_default="de"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
)
op.create_index("ix_users_email", "users", ["email"], unique=True)
def downgrade() -> None:
op.drop_index("ix_users_email", table_name="users")
op.drop_table("users")

View File

@@ -0,0 +1,59 @@
"""catalog: categories + products
Revision ID: 0003_catalog
Revises: 0002_auth
Create Date: 2026-04-16
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0003_catalog"
down_revision: str | None = "0002_auth"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"categories",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("slug", sa.String(128), nullable=False, unique=True),
sa.Column("name", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("parent_id", sa.Integer(), nullable=True),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.ForeignKeyConstraint(["parent_id"], ["categories.id"], ondelete="SET NULL"),
)
op.create_index("ix_categories_slug", "categories", ["slug"], unique=True)
op.create_table(
"products",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("sku", sa.String(64), nullable=False, unique=True),
sa.Column("name", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("description", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("price", sa.Numeric(10, 2), nullable=False),
sa.Column("currency", sa.String(3), nullable=False, server_default="EUR"),
sa.Column("stock", sa.Integer(), nullable=False, server_default="0"),
sa.Column("active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("image_url", sa.String(500), nullable=False, server_default=""),
sa.Column("category_id", sa.Integer(), nullable=True),
sa.Column("attributes", sa.JSON(), nullable=False, server_default="{}"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.ForeignKeyConstraint(["category_id"], ["categories.id"], ondelete="SET NULL"),
)
op.create_index("ix_products_sku", "products", ["sku"], unique=True)
def downgrade() -> None:
op.drop_index("ix_products_sku", table_name="products")
op.drop_table("products")
op.drop_index("ix_categories_slug", table_name="categories")
op.drop_table("categories")

View File

@@ -0,0 +1,46 @@
"""cart: carts + cart_items
Revision ID: 0004_cart
Revises: 0003_catalog
Create Date: 2026-04-16
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0004_cart"
down_revision: str | None = "0003_catalog"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"carts",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("user_id", sa.Integer(), nullable=False, unique=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
)
op.create_table(
"cart_items",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("cart_id", sa.Integer(), nullable=False),
sa.Column("product_id", sa.Integer(), nullable=False),
sa.Column("qty", sa.Integer(), nullable=False, server_default="1"),
sa.ForeignKeyConstraint(["cart_id"], ["carts.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["product_id"], ["products.id"], ondelete="CASCADE"),
sa.UniqueConstraint("cart_id", "product_id", name="uq_cart_product"),
)
def downgrade() -> None:
op.drop_table("cart_items")
op.drop_table("carts")

View File

@@ -0,0 +1,56 @@
"""orders: orders + order_status_history
Revision ID: 0005_orders
Revises: 0004_cart
Create Date: 2026-04-16
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0005_orders"
down_revision: str | None = "0004_cart"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"orders",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("status", sa.String(32), nullable=False, server_default="paid"),
sa.Column("total", sa.Numeric(10, 2), nullable=False),
sa.Column("currency", sa.String(3), nullable=False, server_default="EUR"),
sa.Column("address", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("payment", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("items", sa.JSON(), nullable=False, server_default="[]"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
)
op.create_table(
"order_status_history",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("order_id", sa.Integer(), nullable=False),
sa.Column("status", sa.String(32), nullable=False),
sa.Column("note", sa.String(500), nullable=False, server_default=""),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.ForeignKeyConstraint(["order_id"], ["orders.id"], ondelete="CASCADE"),
)
def downgrade() -> None:
op.drop_table("order_status_history")
op.drop_table("orders")

View File

@@ -0,0 +1,62 @@
"""ai_core: ai_documents + ai_audit
Revision ID: 0006_ai_core
Revises: 0005_orders
Create Date: 2026-04-16
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from pgvector.sqlalchemy import Vector
from core.config import settings
revision: str = "0006_ai_core"
down_revision: str | None = "0005_orders"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"ai_documents",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("source_type", sa.String(64), nullable=False),
sa.Column("source_id", sa.String(64), nullable=False),
sa.Column("text", sa.Text(), nullable=False),
sa.Column("embedding", Vector(settings.OLLAMA_EMBED_DIM), nullable=False),
sa.Column("meta", sa.JSON(), nullable=False, server_default="{}"),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
)
op.create_index("ix_ai_documents_source_type", "ai_documents", ["source_type"])
op.create_index("ix_ai_documents_source_id", "ai_documents", ["source_id"])
op.create_table(
"ai_audit",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("tool", sa.String(128), nullable=False),
sa.Column("args", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("result", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("ok", sa.Boolean(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
)
def downgrade() -> None:
op.drop_table("ai_audit")
op.drop_index("ix_ai_documents_source_id", table_name="ai_documents")
op.drop_index("ix_ai_documents_source_type", table_name="ai_documents")
op.drop_table("ai_documents")

View File

@@ -0,0 +1,5 @@
import redis
from core.config import settings
redis_client = redis.Redis.from_url(settings.REDIS_URL, decode_responses=True)

93
backend/core/security.py Normal file
View File

@@ -0,0 +1,93 @@
from datetime import UTC, datetime, timedelta
from typing import Any
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from core.config import settings
from core.db import get_db
_ph = PasswordHasher()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
def hash_password(pw: str) -> str:
return _ph.hash(pw)
def verify_password(pw: str, hashed: str) -> bool:
try:
return _ph.verify(hashed, pw)
except VerifyMismatchError:
return False
def _make_token(sub: str, role: str, delta: timedelta, token_type: str) -> str:
exp = datetime.now(UTC) + delta
payload = {"sub": sub, "role": role, "type": token_type, "exp": exp}
return jwt.encode(payload, settings.JWT_SECRET, algorithm="HS256")
def make_access_token(user_id: int, role: str) -> str:
return _make_token(str(user_id), role, timedelta(minutes=settings.JWT_ACCESS_MINUTES), "access")
def make_refresh_token(user_id: int, role: str) -> str:
return _make_token(str(user_id), role, timedelta(days=settings.JWT_REFRESH_DAYS), "refresh")
def decode_token(token: str) -> dict[str, Any]:
try:
return jwt.decode(token, settings.JWT_SECRET, algorithms=["HS256"])
except JWTError as e:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"Invalid token: {e}") from e
def current_user_claims(token: str | None = Depends(oauth2_scheme)) -> dict[str, Any]:
if not token:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Missing token")
claims = decode_token(token)
if claims.get("type") != "access":
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Wrong token type")
return claims
def require_admin(claims: dict = Depends(current_user_claims)) -> dict:
if claims.get("role") != "admin":
raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin role required")
return claims
def optional_user(token: str | None = Depends(oauth2_scheme)) -> dict | None:
if not token:
return None
try:
claims = decode_token(token)
return claims if claims.get("type") == "access" else None
except HTTPException:
return None
def get_current_user_id(claims: dict = Depends(current_user_claims)) -> int:
return int(claims["sub"])
# Re-export for DI-free apps
__all__ = [
"hash_password",
"verify_password",
"make_access_token",
"make_refresh_token",
"decode_token",
"current_user_claims",
"require_admin",
"optional_user",
"get_current_user_id",
"oauth2_scheme",
"get_db",
"Session",
]

265
backend/core/seed.py Normal file
View File

@@ -0,0 +1,265 @@
"""Seed DB with admin + demo customer + categories + 12 clothing products."""
from __future__ import annotations
from core.db import SessionLocal
from core.events import event_bus
from core.security import hash_password
from core.settings_service import set_setting
from apps.auth.models import User
from apps.catalog.models import Category, Product
from apps.catalog.projector import rebuild_all
CATEGORIES = [
{"slug": "oberteile", "name": {"de": "Oberteile", "en": "Tops"}, "sort_order": 1},
{"slug": "hosen", "name": {"de": "Hosen", "en": "Pants"}, "sort_order": 2},
{"slug": "schuhe", "name": {"de": "Schuhe", "en": "Shoes"}, "sort_order": 3},
{"slug": "accessoires", "name": {"de": "Accessoires", "en": "Accessories"}, "sort_order": 4},
]
def _placeholder(color_hex: str, label: str) -> str:
# Simple SVG data URL — zero external dependencies
from urllib.parse import quote
svg = (
f"<svg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'>"
f"<rect width='400' height='400' fill='#{color_hex}'/>"
f"<text x='50%' y='50%' dominant-baseline='middle' text-anchor='middle' "
f"font-family='Arial' font-size='26' fill='#ffffff'>{label}</text>"
f"</svg>"
)
return "data:image/svg+xml;utf8," + quote(svg)
PRODUCTS = [
{
"sku": "PULLI-GRUEN-M",
"name": {"de": "Grüner Kuschelpulli", "en": "Green Cozy Sweater"},
"description": {
"de": "Weicher grüner Pullover aus Bio-Baumwolle, ideal für kühle Tage.",
"en": "Soft green sweater made of organic cotton, perfect for chilly days.",
},
"price": 49.90,
"stock": 20,
"cat": "oberteile",
"color": "green",
"img": ("2e7d32", "Grüner Pulli"),
},
{
"sku": "PULLI-BLAU-L",
"name": {"de": "Blauer Rundhalspullover", "en": "Blue Crewneck Sweater"},
"description": {
"de": "Klassischer blauer Pullover mit Rundhalsausschnitt.",
"en": "Classic blue crewneck sweater.",
},
"price": 42.00,
"stock": 15,
"cat": "oberteile",
"color": "blue",
"img": ("1565c0", "Blauer Pulli"),
},
{
"sku": "TSHIRT-WHITE-M",
"name": {"de": "Weißes Basic T-Shirt", "en": "White Basic T-Shirt"},
"description": {
"de": "Leichtes Basic-Shirt aus 100% Bio-Baumwolle.",
"en": "Light basic shirt made of 100% organic cotton.",
},
"price": 14.90,
"stock": 80,
"cat": "oberteile",
"color": "white",
"img": ("f5f5f5", "Weißes Shirt"),
},
{
"sku": "TSHIRT-BLACK-L",
"name": {"de": "Schwarzes T-Shirt", "en": "Black T-Shirt"},
"description": {
"de": "Klassisches schwarzes Shirt, regular fit.",
"en": "Classic black shirt, regular fit.",
},
"price": 15.90,
"stock": 80,
"cat": "oberteile",
"color": "black",
"img": ("212121", "Schwarzes Shirt"),
},
{
"sku": "JACKE-OUTDOOR-M",
"name": {"de": "Warme Outdoor-Jacke", "en": "Warm Outdoor Jacket"},
"description": {
"de": "Wasserabweisende, wärmende Jacke zum Wandern und Radeln im Herbst.",
"en": "Water-repellent, warm jacket for hiking and cycling in autumn.",
},
"price": 129.00,
"stock": 10,
"cat": "oberteile",
"color": "olive",
"img": ("556b2f", "Outdoor-Jacke"),
},
{
"sku": "JEANS-BLAU-32",
"name": {"de": "Blaue Jeans Straight", "en": "Blue Straight Jeans"},
"description": {
"de": "Gerade geschnittene klassische Jeans in Dunkelblau.",
"en": "Straight-cut classic jeans in dark blue.",
},
"price": 69.00,
"stock": 40,
"cat": "hosen",
"color": "blue",
"img": ("0d47a1", "Blaue Jeans"),
},
{
"sku": "JEANS-SCHWARZ-34",
"name": {"de": "Schwarze Slim Jeans", "en": "Black Slim Jeans"},
"description": {
"de": "Slim-Fit-Jeans in tiefem Schwarz, leicht elastisch.",
"en": "Slim-fit jeans in deep black, slightly stretchy.",
},
"price": 74.00,
"stock": 25,
"cat": "hosen",
"color": "black",
"img": ("1b1b1b", "Schwarze Jeans"),
},
{
"sku": "HOSE-WANDER-L",
"name": {"de": "Wanderhose robust", "en": "Robust Hiking Pants"},
"description": {
"de": "Robuste Wanderhose für alle Jahreszeiten, wasserabweisend.",
"en": "Robust hiking pants for all seasons, water-repellent.",
},
"price": 89.00,
"stock": 18,
"cat": "hosen",
"color": "khaki",
"img": ("6b8e23", "Wanderhose"),
},
{
"sku": "SNEAKER-WEISS-42",
"name": {"de": "Weiße Sneaker", "en": "White Sneakers"},
"description": {
"de": "Zeitlose weiße Sneaker für den Alltag.",
"en": "Timeless white sneakers for everyday use.",
},
"price": 79.00,
"stock": 30,
"cat": "schuhe",
"color": "white",
"img": ("eeeeee", "Weiße Sneaker"),
},
{
"sku": "WANDERSCHUH-43",
"name": {"de": "Wanderschuhe warm", "en": "Warm Hiking Boots"},
"description": {
"de": "Warme Wanderschuhe mit guter Dämpfung und rutschfester Sohle.",
"en": "Warm hiking boots with great cushioning and slip-resistant sole.",
},
"price": 149.00,
"stock": 12,
"cat": "schuhe",
"color": "brown",
"img": ("6d4c41", "Wanderschuhe"),
},
{
"sku": "MUETZE-WOLLE",
"name": {"de": "Wollmütze grün", "en": "Green Wool Beanie"},
"description": {
"de": "Warme grüne Wollmütze für kalte Tage.",
"en": "Warm green wool beanie for cold days.",
},
"price": 19.90,
"stock": 50,
"cat": "accessoires",
"color": "green",
"img": ("388e3c", "Mütze"),
},
{
"sku": "SCHAL-GRAU",
"name": {"de": "Grauer Schal", "en": "Grey Scarf"},
"description": {
"de": "Weicher grauer Schal aus Merinowolle.",
"en": "Soft grey scarf made of merino wool.",
},
"price": 29.00,
"stock": 35,
"cat": "accessoires",
"color": "grey",
"img": ("757575", "Grauer Schal"),
},
]
def seed() -> None:
db = SessionLocal()
try:
# Shop name setting
if not db.query(User).filter_by(email="admin@example.com").first():
set_setting(db, "core.shop_name", "Demo Shop")
# Users
if not db.query(User).filter_by(email="admin@example.com").first():
admin = User(
email="admin@example.com",
password_hash=hash_password("admin123"),
role="admin",
name="Admin",
locale="de",
)
db.add(admin)
if not db.query(User).filter_by(email="kunde@example.com").first():
customer = User(
email="kunde@example.com",
password_hash=hash_password("kunde123"),
role="customer",
name="Demo Kunde",
locale="de",
)
db.add(customer)
db.commit()
# Categories
cats_by_slug: dict[str, Category] = {}
for c in CATEGORIES:
row = db.query(Category).filter_by(slug=c["slug"]).first()
if not row:
row = Category(slug=c["slug"], name=c["name"], sort_order=c["sort_order"])
db.add(row)
db.commit()
db.refresh(row)
event_bus.publish("category.created", {"id": row.id}, db=db)
cats_by_slug[c["slug"]] = row
# Products
for pd in PRODUCTS:
if db.query(Product).filter_by(sku=pd["sku"]).first():
continue
image = _placeholder(*pd["img"])
p = Product(
sku=pd["sku"],
name=pd["name"],
description=pd["description"],
price=pd["price"],
currency="EUR",
stock=pd["stock"],
active=True,
image_url=image,
category_id=cats_by_slug[pd["cat"]].id,
attributes={"color": pd["color"]},
)
db.add(p)
db.commit()
db.refresh(p)
event_bus.publish("product.created", {"id": p.id, "sku": p.sku}, db=db)
# Rebuild Redis cache (idempotent)
rebuild_all(db)
print("Seed complete.")
finally:
db.close()
if __name__ == "__main__":
seed()

View File

@@ -0,0 +1,43 @@
"""Key-Value settings store with Redis projection."""
from __future__ import annotations
import json
from typing import Any
from sqlalchemy import JSON, String
from sqlalchemy.orm import Mapped, Session, mapped_column
from core.db import Base
from core.events import event_bus
from core.redis_client import redis_client
class Setting(Base):
__tablename__ = "settings"
key: Mapped[str] = mapped_column(String(128), primary_key=True)
value: Mapped[dict] = mapped_column(JSON)
def get_setting(db: Session, key: str, default: Any = None) -> Any:
row = db.get(Setting, key)
return row.value.get("v") if row else default
def set_setting(db: Session, key: str, value: Any) -> None:
row = db.get(Setting, key)
if row:
row.value = {"v": value}
else:
row = Setting(key=key, value={"v": value})
db.add(row)
db.commit()
redis_client.set(f"setting:{key}", json.dumps(value))
event_bus.publish("core.settings_updated", {"key": key, "value": value}, db=db)
def get_setting_cached(key: str, default: Any = None) -> Any:
raw = redis_client.get(f"setting:{key}")
if raw is None:
return default
return json.loads(raw)

32
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,32 @@
[project]
name = "shop-backend"
version = "0.1.0"
description = "Shopsystem Core + Apps"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.30",
"sqlalchemy>=2.0",
"alembic>=1.13",
"psycopg[binary]>=3.2",
"redis>=5.0",
"pydantic>=2.9",
"pydantic-settings>=2.5",
"python-jose[cryptography]>=3.3",
"argon2-cffi>=23.1",
"httpx>=0.27",
"pgvector>=0.3",
"pyyaml>=6.0",
"jinja2>=3.1",
"aiosmtplib>=3.0",
"python-multipart>=0.0.9",
"punq>=0.7",
"email-validator>=2.2",
]
[tool.uv]
package = false
[tool.ruff]
line-length = 100
target-version = "py311"

1343
backend/uv.lock generated Normal file

File diff suppressed because it is too large Load Diff

65
docker-compose.yml Normal file
View File

@@ -0,0 +1,65 @@
services:
postgres:
image: pgvector/pgvector:pg16
environment:
POSTGRES_USER: ${POSTGRES_USER:-shop}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-shop}
POSTGRES_DB: ${POSTGRES_DB:-shop}
ports:
- "5432:5432"
volumes:
- ./pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-shop}"]
interval: 5s
timeout: 3s
retries: 10
redis:
image: redis:7-alpine
ports:
- "6379:6379"
meilisearch:
image: getmeili/meilisearch:v1.10
environment:
MEILI_MASTER_KEY: ${MEILI_KEY:-shop-dev-master-key}
MEILI_ENV: development
ports:
- "7700:7700"
volumes:
- ./meili-data:/meili_data
ollama:
image: ollama/ollama:latest
ports:
- "11434:11434"
volumes:
- ./ollama-data:/root/.ollama
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
ollama-init:
image: ollama/ollama:latest
depends_on:
- ollama
volumes:
- ./ollama-data:/root/.ollama
entrypoint: ["/bin/sh", "-c"]
command: >
"sleep 5 &&
OLLAMA_HOST=ollama:11434 ollama pull llama3.1 &&
OLLAMA_HOST=ollama:11434 ollama pull nomic-embed-text &&
echo 'Models ready'"
restart: "no"
mailhog:
image: mailhog/mailhog:latest
ports:
- "1025:1025"
- "8025:8025"

12
frontend/admin/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Admin</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,28 @@
{
"name": "@shop/admin",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@shop/shared": "workspace:*",
"@vueuse/core": "^11.1.0",
"axios": "^1.7.7",
"pinia": "^2.2.4",
"vue": "^3.5.11",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3",
"vite": "^5.4.11",
"vue-tsc": "^2.1.8"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
import { onMounted } from "vue";
import { RouterLink, RouterView, useRoute, useRouter } from "vue-router";
import HealthBadge from "./components/HealthBadge.vue";
import { useAuth } from "./stores/auth";
const auth = useAuth();
const route = useRoute();
const router = useRouter();
onMounted(async () => {
await auth.fetchMe();
if (!auth.isAdmin && route.path !== "/login") {
router.push("/login");
}
});
function logout() {
auth.logout();
router.push("/login");
}
</script>
<template>
<div v-if="route.path === '/login'" class="min-h-screen flex items-center justify-center">
<RouterView />
</div>
<div v-else class="min-h-screen flex">
<aside class="w-56 bg-admin-700 text-white flex flex-col">
<div class="p-4 text-lg font-bold border-b border-admin-600">Shop Admin</div>
<nav class="flex-1 p-2 space-y-1 text-sm">
<RouterLink to="/" class="block px-3 py-2 rounded hover:bg-admin-600" active-class="bg-admin-600">
🏠 Dashboard
</RouterLink>
<RouterLink to="/products" class="block px-3 py-2 rounded hover:bg-admin-600" active-class="bg-admin-600">
📦 Produkte
</RouterLink>
<RouterLink to="/categories" class="block px-3 py-2 rounded hover:bg-admin-600" active-class="bg-admin-600">
🏷 Kategorien
</RouterLink>
<RouterLink to="/orders" class="block px-3 py-2 rounded hover:bg-admin-600" active-class="bg-admin-600">
🛒 Bestellungen
</RouterLink>
<RouterLink to="/settings" class="block px-3 py-2 rounded hover:bg-admin-600" active-class="bg-admin-600">
Einstellungen
</RouterLink>
</nav>
<div class="p-3 border-t border-admin-600 text-xs">
<div class="mb-1">{{ auth.user?.email }}</div>
<button @click="logout" class="underline">Abmelden</button>
</div>
</aside>
<main class="flex-1 overflow-auto">
<div class="bg-white border-b border-gray-200 px-6 py-3 flex items-center justify-between">
<div class="text-sm text-gray-500">Admin-Panel</div>
<HealthBadge />
</div>
<div class="p-6">
<RouterView />
</div>
</main>
</div>
</template>

View File

@@ -0,0 +1,2 @@
import { createApi } from "@shop/shared/api";
export const api = createApi("");

View File

@@ -0,0 +1,137 @@
<script setup lang="ts">
import type { ProposalCard as ProposalCardType } from "@shop/shared/types";
import { computed, ref } from "vue";
import { api } from "../api";
import ProposalCard from "./ProposalCard.vue";
interface CardState {
card: ProposalCardType;
state: "pending" | "confirmed" | "rejected" | "executing" | "success" | "error";
result?: any;
error?: string;
}
const prompt = ref("");
const planning = ref(false);
const planError = ref("");
const cards = ref<CardState[]>([]);
async function plan() {
if (!prompt.value.trim()) return;
planning.value = true;
planError.value = "";
try {
const r = await api.post("/api/ai_admin/plan", { prompt: prompt.value });
cards.value = (r.data.cards || []).map((c: ProposalCardType) => ({ card: c, state: "pending" }));
if (!cards.value.length) planError.value = "Die KI konnte keinen Aktionsplan erzeugen.";
} catch (e: any) {
planError.value = e.response?.data?.detail || e.message;
} finally {
planning.value = false;
}
}
function updateArgs(i: number, args: Record<string, any>) {
cards.value[i].card.args = args;
// remove from 'missing' those that now have values
cards.value[i].card.missing = (cards.value[i].card.missing || []).filter(
(k) => args[k] === undefined || args[k] === null || args[k] === ""
);
}
async function execute(i: number) {
const cs = cards.value[i];
if (cs.card.missing.length) {
cs.state = "error";
cs.error = `Fehlende Felder: ${cs.card.missing.join(", ")}`;
return;
}
cs.state = "executing";
try {
const r = await api.post("/api/ai_admin/execute", {
cards: [{ tool: cs.card.tool, args: cs.card.args }],
});
const res = r.data.results[0];
if (res.ok) {
cs.state = "success";
cs.result = res.result;
} else {
cs.state = "error";
cs.error = res.error;
}
} catch (e: any) {
cs.state = "error";
cs.error = e.response?.data?.detail || e.message;
}
}
function reject(i: number) {
cards.value[i].state = "rejected";
}
async function confirmAll() {
for (let i = 0; i < cards.value.length; i++) {
if (cards.value[i].state === "pending") {
await execute(i);
}
}
}
function reset() {
cards.value = [];
prompt.value = "";
planError.value = "";
}
const pendingCount = computed(() => cards.value.filter((c) => c.state === "pending").length);
</script>
<template>
<div class="card">
<h2 class="text-lg font-semibold mb-2">🤖 KI-Assistent</h2>
<p class="text-sm text-gray-600 mb-3">
Sag, was getan werden soll. Die KI erzeugt nur Vorschläge ausgeführt wird erst nach
deiner Bestätigung. Du kannst auch JSON-Daten reinwerfen.
</p>
<div class="flex gap-2">
<textarea
v-model="prompt"
rows="3"
class="input font-mono text-sm"
placeholder="z.B. 'setze den Shopnamen auf TEST123' oder [{sku:'NEW-1',...}] erstelle diese"
/>
<div class="flex flex-col gap-2">
<button @click="plan" :disabled="planning" class="btn-primary">
{{ planning ? "Plane..." : "Planen" }}
</button>
<button v-if="cards.length" @click="reset" class="btn-secondary">Reset</button>
</div>
</div>
<div v-if="planning" class="text-xs text-gray-500 mt-2">
Lokales LLM rechnet. Bei Bulk-Operationen über viele Items kann das ein paar Minuten dauern.
</div>
<div v-if="planError" class="text-red-600 text-sm mt-2">{{ planError }}</div>
<div v-if="cards.length" class="mt-4">
<div class="flex items-center justify-between mb-3">
<div class="font-medium">{{ cards.length }} Vorschlag(e)</div>
<button v-if="pendingCount > 1" @click="confirmAll" class="btn-success text-sm">
Alle bestätigen ({{ pendingCount }})
</button>
</div>
<div class="space-y-3">
<ProposalCard
v-for="(cs, i) in cards"
:key="i"
:card="cs.card"
:state="cs.state"
:result="cs.result"
:error="cs.error"
@update:args="(a) => updateArgs(i, a)"
@confirm="execute(i)"
@reject="reject(i)"
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { api } from "../api";
const ok = ref<boolean | null>(null);
const info = ref<any>(null);
async function check() {
try {
const r = await api.get("/health");
ok.value = r.data.ok;
info.value = r.data;
} catch {
ok.value = false;
}
}
onMounted(check);
</script>
<template>
<div class="flex items-center gap-2 text-xs text-gray-600">
<span
class="w-2 h-2 rounded-full"
:class="ok === null ? 'bg-gray-300' : ok ? 'bg-green-500' : 'bg-red-500'"
></span>
<span>
{{ ok === null ? "…" : ok ? `Backend online (${info?.apps?.length} Apps)` : "Backend offline" }}
</span>
</div>
</template>

View File

@@ -0,0 +1,137 @@
<script setup lang="ts">
import type { ProposalCard } from "@shop/shared/types";
import { computed, ref } from "vue";
const props = defineProps<{
card: ProposalCard;
state: "pending" | "confirmed" | "rejected" | "executing" | "success" | "error";
result?: any;
error?: string;
}>();
const emit = defineEmits<{
(e: "update:args", args: Record<string, any>): void;
(e: "confirm"): void;
(e: "reject"): void;
}>();
// local editable copy of args
const localArgs = ref<Record<string, any>>({ ...props.card.args });
function update(key: string, value: any) {
localArgs.value = { ...localArgs.value, [key]: value };
emit("update:args", localArgs.value);
}
const props_ = computed<Record<string, any>>(() => {
return (props.card.schema?.properties as Record<string, any>) || {};
});
const requiredKeys = computed<string[]>(
() => (props.card.schema?.required as string[]) || []
);
// union of schema keys + keys already present in args (so unusual fields show too)
const allKeys = computed<string[]>(() => {
const s = new Set<string>([...Object.keys(props_.value), ...Object.keys(localArgs.value || {})]);
return Array.from(s);
});
function isMissing(k: string): boolean {
return props.card.missing?.includes(k) || false;
}
function inputType(schema: any): string {
const t = schema?.type;
if (t === "number" || t === "integer") return "number";
if (t === "boolean") return "checkbox";
return "text";
}
function stringify(v: any) {
if (v === null || v === undefined) return "";
if (typeof v === "object") return JSON.stringify(v);
return String(v);
}
</script>
<template>
<div
class="card border-l-4"
:class="{
'border-l-yellow-400': state === 'pending',
'border-l-blue-500': state === 'executing' || state === 'confirmed',
'border-l-green-500': state === 'success',
'border-l-red-500': state === 'error' || state === 'rejected',
}"
>
<div class="flex items-start justify-between mb-2">
<div class="flex-1">
<div class="text-xs uppercase tracking-wide text-gray-500">{{ card.tool }}</div>
<div class="font-medium">{{ card.preview }}</div>
<div v-if="card.notes" class="text-xs text-gray-500 italic mt-1">{{ card.notes }}</div>
</div>
<span
class="text-xs px-2 py-0.5 rounded-full"
:class="{
'bg-yellow-100 text-yellow-700': state === 'pending',
'bg-blue-100 text-blue-700': state === 'executing' || state === 'confirmed',
'bg-green-100 text-green-700': state === 'success',
'bg-red-100 text-red-700': state === 'error' || state === 'rejected',
}"
>{{ state }}</span>
</div>
<div v-if="allKeys.length" class="grid grid-cols-2 gap-2 mt-3">
<div v-for="k in allKeys" :key="k">
<label class="label">
{{ k }}
<span v-if="requiredKeys.includes(k)" class="text-red-500">*</span>
<span
v-if="isMissing(k)"
class="ml-1 text-xs text-red-600 bg-red-50 px-1 rounded"
>fehlt</span>
</label>
<input
v-if="inputType(props_[k]) === 'checkbox'"
type="checkbox"
:checked="!!localArgs[k]"
:disabled="state !== 'pending'"
@change="(e: any) => update(k, e.target.checked)"
/>
<textarea
v-else-if="typeof localArgs[k] === 'object' && localArgs[k] !== null"
class="input font-mono text-xs"
rows="2"
:disabled="state !== 'pending'"
:value="stringify(localArgs[k])"
@input="(e: any) => {
try { update(k, JSON.parse(e.target.value)); } catch {}
}"
/>
<input
v-else
:type="inputType(props_[k])"
:step="props_[k]?.type === 'number' ? 'any' : undefined"
:value="stringify(localArgs[k])"
:disabled="state !== 'pending'"
@input="(e: any) => update(k, props_[k]?.type === 'number' || props_[k]?.type === 'integer' ? Number(e.target.value) : e.target.value)"
class="input"
:class="{ 'border-red-400': isMissing(k) }"
/>
</div>
</div>
<div v-if="state === 'success' && result" class="mt-3 text-sm text-green-700 bg-green-50 p-2 rounded">
Ausgeführt: {{ JSON.stringify(result) }}
</div>
<div v-if="state === 'error' && error" class="mt-3 text-sm text-red-700 bg-red-50 p-2 rounded">
{{ error }}
</div>
<div v-if="state === 'pending'" class="mt-3 flex gap-2">
<button @click="emit('confirm')" class="btn-success">Bestätigen</button>
<button @click="emit('reject')" class="btn-secondary">Verwerfen</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,10 @@
import { createPinia } from "pinia";
import { createApp } from "vue";
import App from "./App.vue";
import "./style.css";
import { router } from "./router";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount("#app");

View File

@@ -0,0 +1,102 @@
<script setup lang="ts">
import type { Category } from "@shop/shared/types";
import { onMounted, reactive, ref } from "vue";
import { api } from "../api";
const categories = ref<Category[]>([]);
const form = reactive({
slug: "",
name_de: "",
name_en: "",
sort_order: 0,
});
const error = ref("");
async function load() {
const r = await api.get("/api/catalog/admin/categories");
categories.value = r.data;
}
async function create() {
error.value = "";
try {
await api.post("/api/catalog/admin/categories", {
slug: form.slug,
name: { de: form.name_de, en: form.name_en },
sort_order: form.sort_order,
});
form.slug = "";
form.name_de = "";
form.name_en = "";
form.sort_order = 0;
await load();
} catch (e: any) {
error.value = e.response?.data?.detail || e.message;
}
}
async function del(c: Category) {
if (!confirm(`Kategorie ${c.slug} löschen?`)) return;
await api.delete(`/api/catalog/admin/categories/${c.id}`);
await load();
}
onMounted(load);
</script>
<template>
<div>
<h1 class="text-2xl font-bold mb-4">Kategorien</h1>
<div class="grid md:grid-cols-[1fr_320px] gap-6">
<div class="card">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Slug</th>
<th>Name (DE)</th>
<th>Sortierung</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="c in categories" :key="c.id">
<td>{{ c.id }}</td>
<td class="font-mono text-xs">{{ c.slug }}</td>
<td>{{ c.name.de }}</td>
<td>{{ c.sort_order }}</td>
<td class="text-right">
<button @click="del(c)" class="text-red-500 text-xs">Löschen</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<h2 class="font-semibold mb-3">Neue Kategorie</h2>
<form @submit.prevent="create" class="space-y-2">
<div>
<label class="label">Slug</label>
<input v-model="form.slug" class="input" required />
</div>
<div>
<label class="label">Name (DE)</label>
<input v-model="form.name_de" class="input" required />
</div>
<div>
<label class="label">Name (EN)</label>
<input v-model="form.name_en" class="input" />
</div>
<div>
<label class="label">Sortierung</label>
<input v-model.number="form.sort_order" type="number" class="input" />
</div>
<button class="btn-primary w-full">Erstellen</button>
<div v-if="error" class="text-red-600 text-sm">{{ error }}</div>
</form>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { api } from "../api";
import AIChatBox from "../components/AIChatBox.vue";
const stats = ref({ products: 0, categories: 0, orders: 0 });
onMounted(async () => {
try {
const [p, c, o] = await Promise.all([
api.get("/api/catalog/admin/products"),
api.get("/api/catalog/admin/categories"),
api.get("/api/orders/admin"),
]);
stats.value = {
products: p.data.length,
categories: c.data.length,
orders: o.data.length,
};
} catch {
// ignore
}
});
</script>
<template>
<div class="space-y-6">
<h1 class="text-2xl font-bold">Dashboard</h1>
<div class="grid grid-cols-3 gap-4">
<div class="card">
<div class="text-sm text-gray-500">Produkte</div>
<div class="text-3xl font-bold">{{ stats.products }}</div>
</div>
<div class="card">
<div class="text-sm text-gray-500">Kategorien</div>
<div class="text-3xl font-bold">{{ stats.categories }}</div>
</div>
<div class="card">
<div class="text-sm text-gray-500">Bestellungen</div>
<div class="text-3xl font-bold">{{ stats.orders }}</div>
</div>
</div>
<AIChatBox />
<div class="card">
<h2 class="font-semibold mb-2">Beispiele für die KI-Box</h2>
<ul class="list-disc pl-5 space-y-1 text-sm text-gray-700">
<li>
<code class="bg-gray-100 px-1">setze den Shopnamen auf TEST123</code>
</li>
<li>
<code class="bg-gray-100 px-1">ändere den Preis von Produkt 1 auf 44.90</code>
</li>
<li>
JSON-Bulk:
<pre class="bg-gray-50 p-2 rounded mt-1 text-xs overflow-x-auto">Das sind neue Produkte, erstelle sie:
[
{"sku":"NEW-TS-RED","name_de":"Rotes T-Shirt","price":18.90,"stock":20},
{"sku":"NEW-SOCK","name_de":"Socken","price":4.90,"stock":100},
{"sku":"NEW-CAP","name_de":"Grüne Kappe","price":12.00,"stock":30}
]</pre>
</li>
</ul>
</div>
</div>
</template>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import { ref } from "vue";
import { useRouter } from "vue-router";
import { useAuth } from "../stores/auth";
const auth = useAuth();
const router = useRouter();
const email = ref("admin@example.com");
const password = ref("admin123");
async function submit() {
try {
await auth.login(email.value, password.value);
router.push("/");
} catch {
// message surfaced via auth.error
}
}
</script>
<template>
<div class="card w-96">
<h1 class="text-2xl font-bold mb-1">Admin-Login</h1>
<p class="text-sm text-gray-500 mb-4">Nur für Administratoren.</p>
<form @submit.prevent="submit" class="space-y-3">
<input v-model="email" type="email" placeholder="E-Mail" class="input" />
<input v-model="password" type="password" placeholder="Passwort" class="input" />
<button class="btn-primary w-full" :disabled="auth.loading">
{{ auth.loading ? "..." : "Anmelden" }}
</button>
<div v-if="auth.error" class="text-red-600 text-sm">{{ auth.error }}</div>
</form>
<p class="text-xs text-gray-400 mt-3">Demo: admin@example.com / admin123</p>
</div>
</template>

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import { i18n } from "@shop/shared";
import type { Order } from "@shop/shared/types";
import { onMounted, ref } from "vue";
import { api } from "../api";
const props = defineProps<{ id: string }>();
const order = ref<Order | null>(null);
const newStatus = ref("");
const note = ref("");
const msg = ref("");
async function load() {
const r = await api.get(`/api/orders/admin/${props.id}`);
order.value = r.data;
newStatus.value = r.data.status;
}
async function updateStatus() {
const r = await api.put(`/api/orders/admin/${props.id}/status`, {
status: newStatus.value,
note: note.value,
});
order.value = r.data;
msg.value = "✓ Status aktualisiert";
note.value = "";
setTimeout(() => (msg.value = ""), 2000);
}
onMounted(load);
</script>
<template>
<div v-if="order" class="max-w-3xl">
<h1 class="text-2xl font-bold mb-2">Bestellung #{{ order.id }}</h1>
<div class="text-sm text-gray-500 mb-4">
{{ new Date(order.created_at).toLocaleString() }}
</div>
<div class="grid md:grid-cols-2 gap-4">
<div class="card">
<h2 class="font-semibold mb-2">Artikel</h2>
<div v-for="it in order.items" :key="it.product_id" class="flex justify-between py-1">
<span>{{ i18n.pickI18n(it.name) }} × {{ it.qty }}</span>
<span>{{ it.line_total.toFixed(2) }} </span>
</div>
<hr class="my-2" />
<div class="flex justify-between font-bold">
<span>Gesamt</span>
<span>{{ order.total.toFixed(2) }} {{ order.currency }}</span>
</div>
</div>
<div class="card">
<h2 class="font-semibold mb-2">Adresse</h2>
<div class="text-sm">
{{ order.address.name }}<br />
{{ order.address.street }}<br />
{{ order.address.zip }} {{ order.address.city }}<br />
{{ order.address.country }}
</div>
<h2 class="font-semibold mt-4 mb-2">Zahlung</h2>
<div class="text-sm text-gray-600">
{{ order.payment.method || "dummy" }} Ref {{ order.payment.transaction_id }}
</div>
</div>
<div class="card md:col-span-2">
<h2 class="font-semibold mb-2">Status ändern</h2>
<div class="flex gap-2 items-end">
<div class="flex-1">
<label class="label">Neuer Status</label>
<select v-model="newStatus" class="input">
<option value="paid">paid</option>
<option value="packed">packed</option>
<option value="shipped">shipped</option>
<option value="delivered">delivered</option>
<option value="cancelled">cancelled</option>
</select>
</div>
<div class="flex-1">
<label class="label">Notiz</label>
<input v-model="note" class="input" />
</div>
<button @click="updateStatus" class="btn-primary">Übernehmen</button>
</div>
<div v-if="msg" class="text-green-600 text-sm mt-2">{{ msg }}</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
import type { Order } from "@shop/shared/types";
import { onMounted, ref } from "vue";
import { RouterLink } from "vue-router";
import { api } from "../api";
const orders = ref<Order[]>([]);
async function load() {
const r = await api.get("/api/orders/admin");
orders.value = r.data;
}
onMounted(load);
</script>
<template>
<div>
<h1 class="text-2xl font-bold mb-4">Bestellungen ({{ orders.length }})</h1>
<div class="card">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Datum</th>
<th>Kunde</th>
<th>Status</th>
<th>Gesamt</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="o in orders" :key="o.id">
<td>#{{ o.id }}</td>
<td>{{ new Date(o.created_at).toLocaleString() }}</td>
<td>{{ o.address?.name || "—" }}</td>
<td>
<span
class="inline-block px-2 py-0.5 rounded-full text-xs"
:class="o.status === 'paid' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'"
>{{ o.status }}</span>
</td>
<td class="font-semibold">{{ o.total.toFixed(2) }} {{ o.currency }}</td>
<td class="text-right">
<RouterLink :to="`/orders/${o.id}`" class="text-admin-500 text-xs">Detail</RouterLink>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>

View File

@@ -0,0 +1,156 @@
<script setup lang="ts">
import type { Category, Product } from "@shop/shared/types";
import { onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { api } from "../api";
const props = defineProps<{ id?: string }>();
const router = useRouter();
const form = ref<Partial<Product>>({
sku: "",
name: { de: "", en: "" },
description: { de: "", en: "" },
price: 0,
currency: "EUR",
stock: 0,
active: true,
image_url: "",
category_id: null,
attributes: {},
});
const categories = ref<Category[]>([]);
const msg = ref("");
const error = ref("");
async function loadCategories() {
const r = await api.get("/api/catalog/admin/categories");
categories.value = r.data;
}
async function loadProduct() {
if (!props.id) return;
const r = await api.get(`/api/catalog/admin/products`);
const p = r.data.find((x: Product) => String(x.id) === props.id);
if (p) form.value = p;
}
async function save() {
msg.value = "";
error.value = "";
try {
const body = {
sku: form.value.sku,
name: form.value.name,
description: form.value.description,
price: Number(form.value.price),
currency: form.value.currency,
stock: Number(form.value.stock),
active: form.value.active,
image_url: form.value.image_url,
category_id: form.value.category_id,
attributes: form.value.attributes,
};
if (props.id) {
await api.put(`/api/catalog/admin/products/${props.id}`, body);
} else {
await api.post(`/api/catalog/admin/products`, body);
}
msg.value = "Gespeichert";
setTimeout(() => router.push("/products"), 500);
} catch (e: any) {
error.value = e.response?.data?.detail || e.message;
}
}
async function upload(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
const fd = new FormData();
fd.append("file", file);
const r = await api.post("/api/catalog/admin/upload", fd);
form.value.image_url = r.data.url;
}
onMounted(async () => {
await loadCategories();
await loadProduct();
});
</script>
<template>
<div class="max-w-3xl">
<h1 class="text-2xl font-bold mb-4">{{ props.id ? "Produkt bearbeiten" : "Neues Produkt" }}</h1>
<form @submit.prevent="save" class="card space-y-4">
<div class="grid grid-cols-2 gap-3">
<div>
<label class="label">SKU</label>
<input v-model="form.sku" class="input" required />
</div>
<div>
<label class="label">Kategorie</label>
<select v-model="form.category_id" class="input">
<option :value="null"></option>
<option v-for="c in categories" :key="c.id" :value="c.id">
{{ c.name.de }}
</option>
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="label">Name (DE)</label>
<input v-model="form.name!.de" class="input" required />
</div>
<div>
<label class="label">Name (EN)</label>
<input v-model="form.name!.en" class="input" />
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="label">Beschreibung (DE)</label>
<textarea v-model="form.description!.de" class="input" rows="3" />
</div>
<div>
<label class="label">Beschreibung (EN)</label>
<textarea v-model="form.description!.en" class="input" rows="3" />
</div>
</div>
<div class="grid grid-cols-4 gap-3">
<div>
<label class="label">Preis</label>
<input v-model.number="form.price" type="number" step="0.01" class="input" required />
</div>
<div>
<label class="label">Währung</label>
<input v-model="form.currency" class="input" />
</div>
<div>
<label class="label">Bestand</label>
<input v-model.number="form.stock" type="number" class="input" />
</div>
<div class="flex items-end">
<label class="flex items-center gap-2">
<input v-model="form.active" type="checkbox" />
Aktiv
</label>
</div>
</div>
<div>
<label class="label">Bild</label>
<div class="flex items-center gap-3">
<div v-if="form.image_url" class="w-20 h-20 bg-gray-100 rounded overflow-hidden">
<img :src="form.image_url" class="w-full h-full object-cover" />
</div>
<input type="file" accept="image/*" @change="upload" />
</div>
</div>
<div class="flex gap-2">
<button type="submit" class="btn-primary">Speichern</button>
<button type="button" @click="router.push('/products')" class="btn-secondary">Abbrechen</button>
</div>
<div v-if="msg" class="text-green-600 text-sm">{{ msg }}</div>
<div v-if="error" class="text-red-600 text-sm">{{ error }}</div>
</form>
</div>
</template>

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import { i18n } from "@shop/shared";
import type { Product } from "@shop/shared/types";
import { onMounted, ref } from "vue";
import { RouterLink } from "vue-router";
import { api } from "../api";
const products = ref<Product[]>([]);
const loading = ref(false);
async function load() {
loading.value = true;
try {
const r = await api.get("/api/catalog/admin/products");
products.value = r.data;
} finally {
loading.value = false;
}
}
async function del(p: Product) {
if (!confirm(`Produkt ${p.sku} wirklich löschen?`)) return;
await api.delete(`/api/catalog/admin/products/${p.id}`);
await load();
}
onMounted(load);
</script>
<template>
<div>
<div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-bold">Produkte ({{ products.length }})</h1>
<RouterLink to="/products/new" class="btn-primary">+ Neues Produkt</RouterLink>
</div>
<div class="card">
<div v-if="loading" class="text-gray-500">Lädt...</div>
<table v-else class="data-table">
<thead>
<tr>
<th>ID</th>
<th>SKU</th>
<th>Name (DE)</th>
<th>Preis</th>
<th>Lager</th>
<th>Aktiv</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="p in products" :key="p.id">
<td>{{ p.id }}</td>
<td class="font-mono text-xs">{{ p.sku }}</td>
<td>{{ i18n.pickI18n(p.name, "de") }}</td>
<td>{{ p.price.toFixed(2) }} </td>
<td>{{ p.stock }}</td>
<td>{{ p.active ? "✓" : "" }}</td>
<td class="text-right">
<RouterLink :to="`/products/${p.id}`" class="text-admin-500 text-xs mr-2">Bearbeiten</RouterLink>
<button @click="del(p)" class="text-red-500 text-xs">Löschen</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>

View File

@@ -0,0 +1,69 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { api } from "../api";
const shopName = ref("");
const msg = ref("");
const reindexing = ref(false);
const reindexResult = ref<any>(null);
async function load() {
try {
const r = await api.get("/api/core/settings/core.shop_name");
shopName.value = r.data.value || "";
} catch {
shopName.value = "";
}
}
async function save() {
msg.value = "";
await api.put("/api/core/settings/core.shop_name", { value: shopName.value });
msg.value = "✓ Gespeichert";
setTimeout(() => (msg.value = ""), 2000);
}
async function reindex() {
reindexing.value = true;
reindexResult.value = null;
try {
const r = await api.post("/api/ai_core/reindex");
reindexResult.value = r.data;
} finally {
reindexing.value = false;
}
}
onMounted(load);
</script>
<template>
<div class="max-w-2xl">
<h1 class="text-2xl font-bold mb-4">Einstellungen</h1>
<div class="card space-y-3">
<div>
<label class="label">Shop-Name (core.shop_name)</label>
<div class="flex gap-2">
<input v-model="shopName" class="input" />
<button @click="save" class="btn-primary">Speichern</button>
</div>
<div v-if="msg" class="text-green-600 text-sm mt-1">{{ msg }}</div>
</div>
</div>
<div class="card mt-4 space-y-3">
<h2 class="font-semibold">KI-Index</h2>
<p class="text-sm text-gray-600">
Baut Embeddings für alle Produkte und Kategorien neu auf (nötig nach Seed oder bei
Inkonsistenzen).
</p>
<button @click="reindex" :disabled="reindexing" class="btn-secondary">
{{ reindexing ? "Indexiere..." : "Neu indizieren" }}
</button>
<div v-if="reindexResult" class="text-sm text-green-700">
{{ reindexResult.products }} Produkte, {{ reindexResult.categories }} Kategorien
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,24 @@
import { createRouter, createWebHistory } from "vue-router";
import Categories from "./pages/Categories.vue";
import Dashboard from "./pages/Dashboard.vue";
import Login from "./pages/Login.vue";
import OrderDetail from "./pages/OrderDetail.vue";
import Orders from "./pages/Orders.vue";
import ProductEdit from "./pages/ProductEdit.vue";
import Products from "./pages/Products.vue";
import Settings from "./pages/Settings.vue";
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/login", component: Login },
{ path: "/", component: Dashboard },
{ path: "/products", component: Products },
{ path: "/products/new", component: ProductEdit },
{ path: "/products/:id", component: ProductEdit, props: true },
{ path: "/categories", component: Categories },
{ path: "/orders", component: Orders },
{ path: "/orders/:id", component: OrderDetail, props: true },
{ path: "/settings", component: Settings },
],
});

View File

@@ -0,0 +1,47 @@
import { clearTokens, saveTokens } from "@shop/shared/api";
import type { User } from "@shop/shared/types";
import { defineStore } from "pinia";
import { api } from "../api";
export const useAuth = defineStore("auth", {
state: () => ({
user: null as User | null,
loading: false,
error: "",
}),
getters: {
isAdmin: (s) => s.user?.role === "admin",
},
actions: {
async fetchMe() {
try {
const r = await api.get("/api/auth/me");
this.user = r.data;
} catch {
this.user = null;
}
},
async login(email: string, password: string) {
this.loading = true;
this.error = "";
try {
const r = await api.post("/api/auth/login", { email, password });
if (r.data.role !== "admin") {
clearTokens();
throw new Error("Nur Admins dürfen sich hier anmelden.");
}
saveTokens(r.data.access_token, r.data.refresh_token);
await this.fetchMe();
} catch (e: any) {
this.error = e.response?.data?.detail || e.message;
throw e;
} finally {
this.loading = false;
}
},
logout() {
clearTokens();
this.user = null;
},
},
});

View File

@@ -0,0 +1,42 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply bg-gray-100 text-gray-900;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}
.btn {
@apply inline-flex items-center justify-center rounded-md px-4 py-2 font-medium transition;
}
.btn-primary {
@apply btn bg-admin-500 text-white hover:bg-admin-600 disabled:opacity-50;
}
.btn-secondary {
@apply btn border border-gray-300 bg-white text-gray-700 hover:bg-gray-50;
}
.btn-danger {
@apply btn bg-red-500 text-white hover:bg-red-600;
}
.btn-success {
@apply btn bg-green-500 text-white hover:bg-green-600;
}
.input {
@apply w-full rounded-md border border-gray-300 px-3 py-2 focus:border-admin-500 focus:outline-none focus:ring-1 focus:ring-admin-500;
}
.card {
@apply rounded-lg bg-white p-4 shadow-sm border border-gray-200;
}
.label {
@apply block text-sm font-medium text-gray-700 mb-1;
}
table.data-table {
@apply w-full text-sm;
}
table.data-table th {
@apply text-left font-medium text-gray-600 border-b border-gray-200 px-2 py-2;
}
table.data-table td {
@apply border-b border-gray-100 px-2 py-2;
}

View File

@@ -0,0 +1,16 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{vue,ts,js}"],
theme: {
extend: {
colors: {
admin: {
500: "#1565c0",
600: "#134a92",
700: "#0d3778",
},
},
},
},
plugins: [],
};

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "preserve",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"paths": {
"@shop/shared": ["../shared/src/index.ts"],
"@shop/shared/*": ["../shared/src/*"]
}
},
"include": ["src/**/*", "src/**/*.vue"]
}

View File

@@ -0,0 +1,15 @@
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [vue()],
server: {
host: true,
port: 5174,
proxy: {
"/api": "http://localhost:8000",
"/uploads": "http://localhost:8000",
"/health": "http://localhost:8000",
},
},
});

5
frontend/package.json Normal file
View File

@@ -0,0 +1,5 @@
{
"name": "shop-frontend-workspace",
"private": true,
"packageManager": "pnpm@10.33.0"
}

1832
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
packages:
- "shared"
- "shop"
- "admin"
onlyBuiltDependencies:
- esbuild

View File

@@ -0,0 +1,16 @@
{
"name": "@shop/shared",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./api": "./src/api.ts",
"./types": "./src/types.ts",
"./i18n": "./src/i18n/index.ts"
},
"dependencies": {
"axios": "^1.7.7"
}
}

View File

@@ -0,0 +1,60 @@
import axios, { type AxiosInstance } from "axios";
const STORAGE_KEY_ACCESS = "shop_access_token";
const STORAGE_KEY_REFRESH = "shop_refresh_token";
export function createApi(baseURL: string): AxiosInstance {
// 10 min — LLM plan calls over many items on a local CPU can take several minutes.
const api = axios.create({ baseURL, timeout: 600000 });
api.interceptors.request.use((cfg) => {
const token = localStorage.getItem(STORAGE_KEY_ACCESS);
if (token) {
cfg.headers = cfg.headers || {};
cfg.headers["Authorization"] = `Bearer ${token}`;
}
return cfg;
});
api.interceptors.response.use(
(r) => r,
async (err) => {
const original = err.config;
if (err.response?.status === 401 && !original._retry) {
const refresh = localStorage.getItem(STORAGE_KEY_REFRESH);
if (refresh) {
original._retry = true;
try {
const resp = await axios.post(`${baseURL}/api/auth/refresh`, {
refresh_token: refresh,
});
localStorage.setItem(STORAGE_KEY_ACCESS, resp.data.access_token);
localStorage.setItem(STORAGE_KEY_REFRESH, resp.data.refresh_token);
original.headers.Authorization = `Bearer ${resp.data.access_token}`;
return api(original);
} catch {
localStorage.removeItem(STORAGE_KEY_ACCESS);
localStorage.removeItem(STORAGE_KEY_REFRESH);
}
}
}
throw err;
}
);
return api;
}
export function saveTokens(access: string, refresh: string): void {
localStorage.setItem(STORAGE_KEY_ACCESS, access);
localStorage.setItem(STORAGE_KEY_REFRESH, refresh);
}
export function clearTokens(): void {
localStorage.removeItem(STORAGE_KEY_ACCESS);
localStorage.removeItem(STORAGE_KEY_REFRESH);
}
export function hasAccess(): boolean {
return !!localStorage.getItem(STORAGE_KEY_ACCESS);
}

View File

@@ -0,0 +1,59 @@
export const de: Record<string, string> = {
// common
"common.loading": "Lädt...",
"common.save": "Speichern",
"common.cancel": "Abbrechen",
"common.delete": "Löschen",
"common.edit": "Bearbeiten",
"common.confirm": "Bestätigen",
"common.back": "Zurück",
"common.name": "Name",
"common.email": "E-Mail",
"common.password": "Passwort",
"common.total": "Gesamt",
"common.empty": "Keine Einträge",
// shop
"shop.title": "Shop",
"shop.home": "Startseite",
"shop.categories": "Kategorien",
"shop.cart": "Warenkorb",
"shop.account": "Mein Konto",
"shop.orders": "Meine Bestellungen",
"shop.login": "Anmelden",
"shop.register": "Registrieren",
"shop.logout": "Abmelden",
"shop.search_placeholder": "Was suchst du? (z.B. 'grüner Pulli')",
"shop.add_to_cart": "In den Warenkorb",
"shop.in_stock": "Auf Lager",
"shop.out_of_stock": "Ausverkauft",
"shop.checkout": "Zur Kasse",
"shop.order_placed": "Bestellung aufgegeben",
"shop.empty_cart": "Warenkorb ist leer",
"shop.continue_shopping": "Weiter einkaufen",
"shop.delivery_address": "Lieferadresse",
"shop.payment_method": "Zahlungsart",
"shop.place_order": "Jetzt bestellen",
"shop.street": "Straße",
"shop.zip": "PLZ",
"shop.city": "Stadt",
"shop.country": "Land",
"shop.no_products": "Keine Produkte gefunden",
// admin
"admin.title": "Admin",
"admin.dashboard": "Dashboard",
"admin.products": "Produkte",
"admin.categories": "Kategorien",
"admin.orders": "Bestellungen",
"admin.settings": "Einstellungen",
"admin.chat_placeholder":
"Gib einen Befehl oder wirf JSON rein (z.B. 'setze den Shopnamen auf TEST123')",
"admin.send": "Senden",
"admin.proposals": "Vorschläge",
"admin.confirm_all": "Alle bestätigen",
"admin.missing": "Fehlt",
"admin.add_product": "Produkt hinzufügen",
"admin.add_category": "Kategorie hinzufügen",
"admin.new_status": "Neuer Status",
};

View File

@@ -0,0 +1,56 @@
export const en: Record<string, string> = {
"common.loading": "Loading...",
"common.save": "Save",
"common.cancel": "Cancel",
"common.delete": "Delete",
"common.edit": "Edit",
"common.confirm": "Confirm",
"common.back": "Back",
"common.name": "Name",
"common.email": "Email",
"common.password": "Password",
"common.total": "Total",
"common.empty": "No entries",
"shop.title": "Shop",
"shop.home": "Home",
"shop.categories": "Categories",
"shop.cart": "Cart",
"shop.account": "My account",
"shop.orders": "My orders",
"shop.login": "Log in",
"shop.register": "Sign up",
"shop.logout": "Log out",
"shop.search_placeholder": "What are you looking for? (e.g. 'green sweater')",
"shop.add_to_cart": "Add to cart",
"shop.in_stock": "In stock",
"shop.out_of_stock": "Out of stock",
"shop.checkout": "Checkout",
"shop.order_placed": "Order placed",
"shop.empty_cart": "Cart is empty",
"shop.continue_shopping": "Continue shopping",
"shop.delivery_address": "Delivery address",
"shop.payment_method": "Payment method",
"shop.place_order": "Place order",
"shop.street": "Street",
"shop.zip": "ZIP",
"shop.city": "City",
"shop.country": "Country",
"shop.no_products": "No products found",
"admin.title": "Admin",
"admin.dashboard": "Dashboard",
"admin.products": "Products",
"admin.categories": "Categories",
"admin.orders": "Orders",
"admin.settings": "Settings",
"admin.chat_placeholder":
"Enter a command or paste JSON (e.g. 'set the shop name to TEST123')",
"admin.send": "Send",
"admin.proposals": "Proposals",
"admin.confirm_all": "Confirm all",
"admin.missing": "Missing",
"admin.add_product": "Add product",
"admin.add_category": "Add category",
"admin.new_status": "New status",
};

View File

@@ -0,0 +1,26 @@
import type { I18nText, Locale } from "../types";
import { de } from "./de";
import { en } from "./en";
const dicts: Record<Locale, Record<string, string>> = { de, en };
let current: Locale = (localStorage.getItem("locale") as Locale) || "de";
export function setLocale(loc: Locale) {
current = loc;
localStorage.setItem("locale", loc);
}
export function getLocale(): Locale {
return current;
}
export function t(key: string): string {
return dicts[current]?.[key] || dicts.de[key] || key;
}
export function pickI18n(txt: I18nText | undefined | null, loc?: Locale): string {
if (!txt) return "";
const l = loc || current;
return txt[l] || txt.de || txt.en || "";
}

View File

@@ -0,0 +1,3 @@
export * from "./types";
export * from "./api";
export * as i18n from "./i18n";

View File

@@ -0,0 +1,89 @@
export type Locale = "de" | "en";
export interface I18nText {
de?: string;
en?: string;
}
export interface User {
id: number;
email: string;
name: string;
role: "customer" | "admin";
locale: Locale;
}
export interface Category {
id: number;
slug: string;
name: I18nText;
parent_id: number | null;
sort_order: number;
}
export interface Product {
id: number;
sku: string;
name: I18nText;
description: I18nText;
price: number;
currency: string;
stock: number;
active: boolean;
image_url: string;
category_id: number | null;
attributes: Record<string, any>;
_score?: number;
}
export interface CartItem {
product_id: number;
qty: number;
name: I18nText;
price: number;
image_url: string;
line_total: number;
}
export interface Cart {
items: CartItem[];
subtotal: number;
}
export interface OrderItem {
product_id: number;
sku: string;
name: I18nText;
price: number;
qty: number;
line_total: number;
}
export interface Order {
id: number;
user_id: number | null;
status: string;
total: number;
currency: string;
address: Record<string, string>;
payment: Record<string, any>;
items: OrderItem[];
created_at: string;
}
export interface ProposalCard {
tool: string;
args: Record<string, any>;
missing: string[];
preview: string;
notes?: string;
schema: Record<string, any>;
}
export interface TokenResponse {
access_token: string;
refresh_token: string;
token_type: string;
role: string;
user_id: number;
}

12
frontend/shop/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Shop</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,28 @@
{
"name": "@shop/shop",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@shop/shared": "workspace:*",
"@vueuse/core": "^11.1.0",
"axios": "^1.7.7",
"pinia": "^2.2.4",
"vue": "^3.5.11",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3",
"vite": "^5.4.11",
"vue-tsc": "^2.1.8"
}
}

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