wahnsinn vibe
This commit is contained in:
1
.claude/scheduled_tasks.lock
Normal file
1
.claude/scheduled_tasks.lock
Normal file
@@ -0,0 +1 @@
|
||||
{"sessionId":"e480a2be-e00b-4a51-8cfc-f525e281eccb","pid":17506,"acquiredAt":1776357371970}
|
||||
39
.env.example
Normal file
39
.env.example
Normal 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
16
.gitignore
vendored
Normal 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/
|
||||
13
CLAUDE.md
13
CLAUDE.md
@@ -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
65
Makefile
Normal 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
126
QUICKSTART.md
Normal 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 10–20 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
39
backend/alembic.ini
Normal 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
0
backend/apps/__init__.py
Normal file
72
backend/apps/ai_admin/__init__.py
Normal file
72
backend/apps/ai_admin/__init__.py
Normal 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()
|
||||
6
backend/apps/ai_admin/manifest.yaml
Normal file
6
backend/apps/ai_admin/manifest.yaml
Normal 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: []
|
||||
113
backend/apps/ai_admin/planner.py
Normal file
113
backend/apps/ai_admin/planner.py
Normal 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
|
||||
227
backend/apps/ai_admin/tool_defs.py
Normal file
227
backend/apps/ai_admin/tool_defs.py
Normal 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)
|
||||
69
backend/apps/ai_core/__init__.py
Normal file
69
backend/apps/ai_core/__init__.py
Normal 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)
|
||||
158
backend/apps/ai_core/indexer.py
Normal file
158
backend/apps/ai_core/indexer.py
Normal 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)
|
||||
6
backend/apps/ai_core/manifest.yaml
Normal file
6
backend/apps/ai_core/manifest.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
name: ai_core
|
||||
version: 0.1.0
|
||||
depends_on: [core, catalog]
|
||||
conflicts_with: []
|
||||
required: true
|
||||
provides: [LLMProvider, ToolRegistry]
|
||||
34
backend/apps/ai_core/models.py
Normal file
34
backend/apps/ai_core/models.py
Normal 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())
|
||||
69
backend/apps/ai_core/ollama_client.py
Normal file
69
backend/apps/ai_core/ollama_client.py
Normal 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
|
||||
6
backend/apps/ai_core/reindex.py
Normal file
6
backend/apps/ai_core/reindex.py
Normal 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}")
|
||||
56
backend/apps/ai_core/tools.py
Normal file
56
backend/apps/ai_core/tools.py
Normal 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, "")]
|
||||
111
backend/apps/ai_shop/__init__.py
Normal file
111
backend/apps/ai_shop/__init__.py
Normal 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]}
|
||||
6
backend/apps/ai_shop/manifest.yaml
Normal file
6
backend/apps/ai_shop/manifest.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
name: ai_shop
|
||||
version: 0.1.0
|
||||
depends_on: [core, catalog, ai_core]
|
||||
conflicts_with: []
|
||||
required: false
|
||||
provides: []
|
||||
155
backend/apps/auth/__init__.py
Normal file
155
backend/apps/auth/__init__.py
Normal 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}
|
||||
6
backend/apps/auth/manifest.yaml
Normal file
6
backend/apps/auth/manifest.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
name: auth
|
||||
version: 0.1.0
|
||||
depends_on: [core]
|
||||
conflicts_with: []
|
||||
required: true
|
||||
provides: [UserService]
|
||||
18
backend/apps/auth/models.py
Normal file
18
backend/apps/auth/models.py
Normal 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())
|
||||
140
backend/apps/cart/__init__.py
Normal file
140
backend/apps/cart/__init__.py
Normal 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)
|
||||
6
backend/apps/cart/manifest.yaml
Normal file
6
backend/apps/cart/manifest.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
name: cart
|
||||
version: 0.1.0
|
||||
depends_on: [core, auth, catalog]
|
||||
conflicts_with: []
|
||||
required: true
|
||||
provides: [CartService]
|
||||
30
backend/apps/cart/models.py
Normal file
30
backend/apps/cart/models.py
Normal 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")
|
||||
242
backend/apps/catalog/__init__.py
Normal file
242
backend/apps/catalog/__init__.py
Normal 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,
|
||||
)
|
||||
6
backend/apps/catalog/manifest.yaml
Normal file
6
backend/apps/catalog/manifest.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
name: catalog
|
||||
version: 0.1.0
|
||||
depends_on: [core, auth]
|
||||
conflicts_with: []
|
||||
required: true
|
||||
provides: [ProductService, CategoryService]
|
||||
39
backend/apps/catalog/models.py
Normal file
39
backend/apps/catalog/models.py
Normal 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")
|
||||
87
backend/apps/catalog/projector.py
Normal file
87
backend/apps/catalog/projector.py
Normal 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)
|
||||
106
backend/apps/checkout/__init__.py
Normal file
106
backend/apps/checkout/__init__.py
Normal 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}
|
||||
6
backend/apps/checkout/manifest.yaml
Normal file
6
backend/apps/checkout/manifest.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
name: checkout
|
||||
version: 0.1.0
|
||||
depends_on: [core, auth, catalog, cart, payment]
|
||||
conflicts_with: []
|
||||
required: true
|
||||
provides: []
|
||||
8
backend/apps/hello/__init__.py
Normal file
8
backend/apps/hello/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/ping")
|
||||
def ping():
|
||||
return {"msg": "hello from dummy app"}
|
||||
6
backend/apps/hello/manifest.yaml
Normal file
6
backend/apps/hello/manifest.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
name: hello
|
||||
version: 0.1.0
|
||||
depends_on: [core]
|
||||
conflicts_with: []
|
||||
required: false
|
||||
provides: []
|
||||
51
backend/apps/mail/__init__.py
Normal file
51
backend/apps/mail/__init__.py
Normal 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())
|
||||
6
backend/apps/mail/manifest.yaml
Normal file
6
backend/apps/mail/manifest.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
name: mail
|
||||
version: 0.1.0
|
||||
depends_on: [core]
|
||||
conflicts_with: []
|
||||
required: true
|
||||
provides: [MailService]
|
||||
175
backend/apps/orders/__init__.py
Normal file
175
backend/apps/orders/__init__.py
Normal 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)
|
||||
6
backend/apps/orders/manifest.yaml
Normal file
6
backend/apps/orders/manifest.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
name: orders
|
||||
version: 0.1.0
|
||||
depends_on: [core, auth, catalog, mail]
|
||||
conflicts_with: []
|
||||
required: true
|
||||
provides: [OrderService]
|
||||
34
backend/apps/orders/models.py
Normal file
34
backend/apps/orders/models.py
Normal 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())
|
||||
34
backend/apps/payment/__init__.py
Normal file
34
backend/apps/payment/__init__.py
Normal 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"},
|
||||
]
|
||||
6
backend/apps/payment/manifest.yaml
Normal file
6
backend/apps/payment/manifest.yaml
Normal 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
0
backend/core/__init__.py
Normal file
37
backend/core/config.py
Normal file
37
backend/core/config.py
Normal 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
21
backend/core/db.py
Normal 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
18
backend/core/di.py
Normal 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
62
backend/core/events.py
Normal 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
16
backend/core/i18n.py
Normal 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
99
backend/core/loader.py
Normal 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
89
backend/core/main.py
Normal 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"])
|
||||
63
backend/core/migrations/env.py
Normal file
63
backend/core/migrations/env.py
Normal 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()
|
||||
26
backend/core/migrations/script.py.mako
Normal file
26
backend/core/migrations/script.py.mako
Normal 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"}
|
||||
47
backend/core/migrations/versions/0001_core_init.py
Normal file
47
backend/core/migrations/versions/0001_core_init.py
Normal 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")
|
||||
40
backend/core/migrations/versions/0002_auth_users.py
Normal file
40
backend/core/migrations/versions/0002_auth_users.py
Normal 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")
|
||||
59
backend/core/migrations/versions/0003_catalog.py
Normal file
59
backend/core/migrations/versions/0003_catalog.py
Normal 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")
|
||||
46
backend/core/migrations/versions/0004_cart.py
Normal file
46
backend/core/migrations/versions/0004_cart.py
Normal 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")
|
||||
56
backend/core/migrations/versions/0005_orders.py
Normal file
56
backend/core/migrations/versions/0005_orders.py
Normal 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")
|
||||
62
backend/core/migrations/versions/0006_ai_core.py
Normal file
62
backend/core/migrations/versions/0006_ai_core.py
Normal 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")
|
||||
5
backend/core/redis_client.py
Normal file
5
backend/core/redis_client.py
Normal 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
93
backend/core/security.py
Normal 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
265
backend/core/seed.py
Normal 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()
|
||||
43
backend/core/settings_service.py
Normal file
43
backend/core/settings_service.py
Normal 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
32
backend/pyproject.toml
Normal 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
1343
backend/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
65
docker-compose.yml
Normal file
65
docker-compose.yml
Normal 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
12
frontend/admin/index.html
Normal 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>
|
||||
28
frontend/admin/package.json
Normal file
28
frontend/admin/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/admin/postcss.config.js
Normal file
6
frontend/admin/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
64
frontend/admin/src/App.vue
Normal file
64
frontend/admin/src/App.vue
Normal 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>
|
||||
2
frontend/admin/src/api.ts
Normal file
2
frontend/admin/src/api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import { createApi } from "@shop/shared/api";
|
||||
export const api = createApi("");
|
||||
137
frontend/admin/src/components/AIChatBox.vue
Normal file
137
frontend/admin/src/components/AIChatBox.vue
Normal 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>
|
||||
31
frontend/admin/src/components/HealthBadge.vue
Normal file
31
frontend/admin/src/components/HealthBadge.vue
Normal 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>
|
||||
137
frontend/admin/src/components/ProposalCard.vue
Normal file
137
frontend/admin/src/components/ProposalCard.vue
Normal 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>
|
||||
10
frontend/admin/src/main.ts
Normal file
10
frontend/admin/src/main.ts
Normal 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");
|
||||
102
frontend/admin/src/pages/Categories.vue
Normal file
102
frontend/admin/src/pages/Categories.vue
Normal 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>
|
||||
68
frontend/admin/src/pages/Dashboard.vue
Normal file
68
frontend/admin/src/pages/Dashboard.vue
Normal 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>
|
||||
35
frontend/admin/src/pages/Login.vue
Normal file
35
frontend/admin/src/pages/Login.vue
Normal 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>
|
||||
91
frontend/admin/src/pages/OrderDetail.vue
Normal file
91
frontend/admin/src/pages/OrderDetail.vue
Normal 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>
|
||||
52
frontend/admin/src/pages/Orders.vue
Normal file
52
frontend/admin/src/pages/Orders.vue
Normal 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>
|
||||
156
frontend/admin/src/pages/ProductEdit.vue
Normal file
156
frontend/admin/src/pages/ProductEdit.vue
Normal 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>
|
||||
67
frontend/admin/src/pages/Products.vue
Normal file
67
frontend/admin/src/pages/Products.vue
Normal 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>
|
||||
69
frontend/admin/src/pages/Settings.vue
Normal file
69
frontend/admin/src/pages/Settings.vue
Normal 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>
|
||||
24
frontend/admin/src/router.ts
Normal file
24
frontend/admin/src/router.ts
Normal 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 },
|
||||
],
|
||||
});
|
||||
47
frontend/admin/src/stores/auth.ts
Normal file
47
frontend/admin/src/stores/auth.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
||||
42
frontend/admin/src/style.css
Normal file
42
frontend/admin/src/style.css
Normal 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;
|
||||
}
|
||||
16
frontend/admin/tailwind.config.js
Normal file
16
frontend/admin/tailwind.config.js
Normal 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: [],
|
||||
};
|
||||
22
frontend/admin/tsconfig.json
Normal file
22
frontend/admin/tsconfig.json
Normal 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"]
|
||||
}
|
||||
15
frontend/admin/vite.config.ts
Normal file
15
frontend/admin/vite.config.ts
Normal 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
5
frontend/package.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "shop-frontend-workspace",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.33.0"
|
||||
}
|
||||
1832
frontend/pnpm-lock.yaml
generated
Normal file
1832
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
frontend/pnpm-workspace.yaml
Normal file
6
frontend/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
packages:
|
||||
- "shared"
|
||||
- "shop"
|
||||
- "admin"
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
16
frontend/shared/package.json
Normal file
16
frontend/shared/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
60
frontend/shared/src/api.ts
Normal file
60
frontend/shared/src/api.ts
Normal 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);
|
||||
}
|
||||
59
frontend/shared/src/i18n/de.ts
Normal file
59
frontend/shared/src/i18n/de.ts
Normal 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",
|
||||
};
|
||||
56
frontend/shared/src/i18n/en.ts
Normal file
56
frontend/shared/src/i18n/en.ts
Normal 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",
|
||||
};
|
||||
26
frontend/shared/src/i18n/index.ts
Normal file
26
frontend/shared/src/i18n/index.ts
Normal 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 || "";
|
||||
}
|
||||
3
frontend/shared/src/index.ts
Normal file
3
frontend/shared/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./types";
|
||||
export * from "./api";
|
||||
export * as i18n from "./i18n";
|
||||
89
frontend/shared/src/types.ts
Normal file
89
frontend/shared/src/types.ts
Normal 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
12
frontend/shop/index.html
Normal 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>
|
||||
28
frontend/shop/package.json
Normal file
28
frontend/shop/package.json
Normal 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
Reference in New Issue
Block a user