diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3cf62ea --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +DATABASE_URL=postgresql+psycopg://olympus:change-me@postgres:5432/olympus +SECRET_KEY=change-me +ACCESS_TOKEN_EXPIRE_MINUTES=60 +JWT_ISSUER=hermes +LOG_LEVEL=INFO +AUTH_COOKIE_SECURE=false +ATHENA_PUBLIC_ORIGIN=http://localhost:3001 + +# Optional fuer neue Installationen ohne aktive Benutzer. +INITIAL_ADMIN_USERNAME= +INITIAL_ADMIN_EMAIL= +INITIAL_ADMIN_PASSWORD= +INITIAL_ADMIN_FIRST_NAME= +INITIAL_ADMIN_LAST_NAME= diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 870d764..1cb2ecc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -93,6 +93,12 @@ Hermes setzt kein Cookie. 2. Athena loescht das HttpOnly-Cookie. 3. Der Benutzer wird zur Login-Seite gefuehrt. +### Aktueller Benutzer + +Athena stellt `GET /api/me` bereit und ruft dafuer serverseitig Hermes `GET /auth/me` auf. Der Header laedt den aktuellen Benutzer ausschliesslich ueber diese BFF-Route und zeigt bevorzugt Vorname plus Nachname, danach den Benutzernamen und vor dem Laden `Benutzer`. + +Der Browser erhaelt dabei kein Token und ruft Hermes nicht direkt auf. + ### HttpOnly Cookie Das Auth-Cookie wird von Athena gesetzt. @@ -238,6 +244,56 @@ Kunden: - `customers.read` - `customers.create` - `customers.update` + +### Initial Admin Bootstrap + +Hermes prueft beim Startup nach dem RBAC-Seed, ob mindestens ein aktiver Benutzer existiert. Existiert ein aktiver Benutzer, wird kein Benutzer automatisch erstellt. + +Existiert kein aktiver Benutzer, kann Hermes ueber optionale Umgebungsvariablen einen initialen Administrator anlegen: + +- `INITIAL_ADMIN_USERNAME` +- `INITIAL_ADMIN_EMAIL` +- `INITIAL_ADMIN_PASSWORD` +- `INITIAL_ADMIN_FIRST_NAME` +- `INITIAL_ADMIN_LAST_NAME` + +Benutzername, E-Mail und Passwort sind fuer die automatische Anlage erforderlich. Fehlen Werte oder ist die E-Mail ungueltig, startet Hermes weiter und schreibt nur eine Warnung ohne Secrets. Das Passwort wird mit der bestehenden `hash_password`-Funktion gehasht. Der Benutzer erhaelt `role_id` der Systemrolle `administrator` und das Legacy-Feld `role=administrator`. + +### Kundenimport + +Hermes stellt fuer CSV-Importe zwei Endpunkte bereit: + +- `POST /customers/import/preview` +- `POST /customers/import/commit` + +Athena stellt die Browser-BFF-Routen bereit: + +- `POST /api/customers/import/preview` +- `POST /api/customers/import/commit` +- `GET /api/customers/import/template` + +Der Browser sendet Multipart-FormData nur an Athena. Athena leitet die Datei serverseitig mit Bearer Token aus dem HttpOnly-Cookie an Hermes weiter. Hermes begrenzt CSV-Dateien auf 5 MB, bevorzugt UTF-8, erkennt Semikolon und Komma und ignoriert leere Zeilen. + +Importmodi: + +- `create_only`: neue Kunden erstellen, bestehende Kundennummern ueberspringen +- `update_existing`: bestehende Kunden anhand der Kundennummer aktualisieren +- `upsert`: bestehende Kunden aktualisieren und neue Kunden erstellen + +RBAC: + +- Preview benoetigt `customers.read` +- Commit mit `create_only` benoetigt `customers.create` +- Commit mit `update_existing` benoetigt `customers.update` +- Commit mit `upsert` benoetigt `customers.create` und `customers.update` + +CSV-Spalten: + +```text +customer_number;company_name;legal_name;customer_type;status;industry;website;email;phone;tax_number;vat_id;notes;address_type;street;postal_code;city;state;country;address_is_primary;contact_first_name;contact_last_name;contact_position;contact_email;contact_phone;contact_mobile;contact_is_primary;contact_notes +``` + +Pflichtfeld ist `company_name`. Wenn `customer_number`, `customer_type` oder `status` fehlen, erzeugt der Import fuer neue Kunden eine Kundennummer bzw. nutzt produktive Defaults und weist in der Preview darauf hin. Vollstaendige CSV-Inhalte werden nicht im Audit Log gespeichert. - `customers.delete` Projekte: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f4e323d..d64d2c8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -105,6 +105,7 @@ uv run alembic upgrade head - Erfolg und Fehler in mutierenden CRUD-Flows ueber den Toast-Provider melden. - Keine Browser-Dialoge wie `alert()` oder `confirm()`. - Keine Tokens in Browser-JavaScript speichern. +- Datei-Uploads vom Browser laufen ueber Athena-BFF-Routen und werden serverseitig an Hermes weitergeleitet. ### Backend @@ -118,6 +119,7 @@ uv run alembic upgrade head - Berechtigungen mit `require_permission`, `require_any_permission` oder `require_all_permissions` pruefen. - Mutierende Kernaktionen mit Audit Logs erfassen, sofern fachlich relevant. - Sensible Felder vor Persistenz in Logs oder Audit-Daten maskieren. +- Import- und Bootstrap-Flows duerfen keine Passwoerter, Tokens, Secrets oder vollstaendige CSV-Inhalte loggen. ### Allgemein @@ -143,6 +145,7 @@ Diese Regeln sind verbindlich: - Frontend-Permissions dienen nur der UI und ersetzen keine Backend-Pruefung. - Audit Logs werden serverseitig in Hermes geschrieben. - Neue Plattform-Endpunkte sollen das einheitliche API-Response-Format nutzen. +- Header- und Benutzerkontext wird ueber `GET /api/me` geladen; hart codierte Benutzernamen sind unzulaessig. Wenn eine Aufgabe diese Regeln zu verletzen scheint, muss zuerst die Architekturentscheidung geklaert werden. @@ -196,6 +199,31 @@ Ein neues CRM-Modul soll sich am Kundenmodul orientieren: Dashboard-Widgets fuer noch nicht implementierte Module muessen Empty States anzeigen statt hart codierter Beispieldaten. +### Kundenimport + +Kundenimporte muessen die bestehende Kundenarchitektur nutzen und ueber Athena-BFF-Routen laufen. Der Browser darf keine Hermes-URL kennen. + +CSV-Vorgaben: + +- UTF-8 bevorzugen +- Semikolon und Komma unterstuetzen +- leere Zeilen ignorieren +- Fehler und Warnungen pro Zeile zurueckgeben +- Preview darf keine Datenbank-Aenderungen ausloesen +- Commit schreibt Audit Logs ohne CSV-Rohinhalt + +Importmodi: + +- `create_only` +- `update_existing` +- `upsert` + +Die Excel-freundliche Vorlage liegt unter `GET /api/customers/import/template`. + +### Deployment + +Neue Installationen koennen optional ueber `INITIAL_ADMIN_*` einen initialen Administrator anlegen. Diese Variablen werden nur von Hermes gelesen und duerfen nicht im Frontend oder in Logs erscheinen. + ### Neue Permissions Neue Module muessen eigene stabile Permission-Strings erhalten. diff --git a/ROADMAP.md b/ROADMAP.md index 764f49c..f4c7770 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -34,6 +34,16 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi - Globale Toasts fuer CRUD-Erfolg und Fehler - Docker-Konfiguration fuer Logging-Level +## v0.5.1 - Bootstrap und Kundenimport + +- Initial-Admin-Bootstrap fuer neue Installationen ohne aktive Benutzer +- Optionale `INITIAL_ADMIN_*` Deployment-Variablen +- Dynamische Header-Benutzeranzeige ueber `GET /api/me` +- CSV-Kundenimport mit Preview und Commit +- Importmodi `create_only`, `update_existing` und `upsert` +- Excel-freundliche CSV-Vorlage ueber Athena +- Audit Log fuer Kundenimporte ohne CSV-Rohdaten + ## v0.6.0 - Projektmodul, geplant - Projektstammdaten diff --git a/backend/hermes/.env.example b/backend/hermes/.env.example index 6035eb9..865387d 100644 --- a/backend/hermes/.env.example +++ b/backend/hermes/.env.example @@ -4,3 +4,12 @@ SECRET_KEY=CHANGE_ME APP_NAME=Hermes API APP_VERSION=0.1.0 +ACCESS_TOKEN_EXPIRE_MINUTES=60 +JWT_ISSUER=hermes +LOG_LEVEL=INFO + +INITIAL_ADMIN_USERNAME= +INITIAL_ADMIN_EMAIL= +INITIAL_ADMIN_PASSWORD= +INITIAL_ADMIN_FIRST_NAME= +INITIAL_ADMIN_LAST_NAME= diff --git a/backend/hermes/app/api/auth.py b/backend/hermes/app/api/auth.py index 0660f5d..1e7df8d 100644 --- a/backend/hermes/app/api/auth.py +++ b/backend/hermes/app/api/auth.py @@ -81,6 +81,8 @@ def login( def me(current_user: User = Depends(get_current_active_user)): return { "id": current_user.id, + "first_name": current_user.first_name, + "last_name": current_user.last_name, "username": current_user.username, "email": current_user.email, "role": current_user.primary_role.name, diff --git a/backend/hermes/app/api/customers.py b/backend/hermes/app/api/customers.py index d26e1be..32158ae 100644 --- a/backend/hermes/app/api/customers.py +++ b/backend/hermes/app/api/customers.py @@ -1,10 +1,10 @@ import logging -from fastapi import APIRouter, Depends, HTTPException, Response, status +from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile, status from sqlalchemy.orm import Session from starlette.requests import Request -from app.core.rbac import require_permission +from app.core.rbac import get_current_active_user, get_user_permission_names, require_permission from app.db.database import get_db from app.models.customer import Customer, CustomerContact from app.models.user import User @@ -17,7 +17,13 @@ from app.schemas.customer import ( CustomerResponse, CustomerUpdate, ) +from app.schemas.customer_import import ( + CustomerImportCommitResponse, + CustomerImportPreviewResponse, + ImportMode, +) from app.services.audit_service import sanitize, write_audit_log +from app.services.customer_import_service import CustomerImportService logger = logging.getLogger(__name__) @@ -26,6 +32,23 @@ router = APIRouter( tags=["Customers"], ) +MAX_IMPORT_SIZE_BYTES = 5 * 1024 * 1024 + + +async def read_import_file(file: UploadFile) -> bytes: + content = await file.read(MAX_IMPORT_SIZE_BYTES + 1) + if len(content) > MAX_IMPORT_SIZE_BYTES: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail="CSV-Datei darf maximal 5 MB groß sein", + ) + if not content: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="CSV-Datei ist leer", + ) + return content + def get_customer_or_404(db: Session, customer_id: int) -> Customer: customer = CustomerRepository.get_by_id(db, customer_id) @@ -116,6 +139,44 @@ def create_customer( return created_customer +@router.post("/import/preview", response_model=CustomerImportPreviewResponse) +async def preview_customer_import( + mode: ImportMode = Form(...), + file: UploadFile = File(...), + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("customers.read")), +): + logger.info("customers.import.preview", extra={"actor_user_id": current_user.id, "mode": mode}) + content = await read_import_file(file) + try: + return CustomerImportService.preview(db, content, mode) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + +@router.post("/import/commit", response_model=CustomerImportCommitResponse) +async def commit_customer_import( + request: Request, + mode: ImportMode = Form(...), + file: UploadFile = File(...), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user), +): + permissions = get_user_permission_names(current_user) + required_permissions = {"customers.create"} if mode == "create_only" else {"customers.update"} + if mode == "upsert": + required_permissions = {"customers.create", "customers.update"} + if not required_permissions.issubset(permissions): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Keine Berechtigung") + + logger.info("customers.import.commit", extra={"actor_user_id": current_user.id, "mode": mode}) + content = await read_import_file(file) + try: + return CustomerImportService.commit(db, content, mode, actor=current_user, request=request) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + @router.put("/{customer_id}", response_model=CustomerResponse) def update_customer( customer_id: int, diff --git a/backend/hermes/app/core/config.py b/backend/hermes/app/core/config.py index 685d618..92fd66e 100644 --- a/backend/hermes/app/core/config.py +++ b/backend/hermes/app/core/config.py @@ -10,6 +10,11 @@ class Settings(BaseSettings): access_token_expire_minutes: int = 60 jwt_issuer: str = "hermes" log_level: str = "INFO" + initial_admin_username: str | None = None + initial_admin_email: str | None = None + initial_admin_password: str | None = None + initial_admin_first_name: str = "" + initial_admin_last_name: str = "" model_config = SettingsConfigDict( env_file=".env", diff --git a/backend/hermes/app/main.py b/backend/hermes/app/main.py index 2ef7a74..4edba3d 100644 --- a/backend/hermes/app/main.py +++ b/backend/hermes/app/main.py @@ -20,6 +20,7 @@ from app.db.database import SessionLocal from app.db.health import check_database from app.core.logging import configure_logging from app.rbac.seed import seed_rbac +from app.services.initial_admin_bootstrap import bootstrap_initial_admin configure_logging() @@ -73,6 +74,7 @@ def startup_seed_rbac(): db = SessionLocal() try: seed_rbac(db) + bootstrap_initial_admin(db) finally: db.close() diff --git a/backend/hermes/app/schemas/customer_import.py b/backend/hermes/app/schemas/customer_import.py new file mode 100644 index 0000000..d9926f3 --- /dev/null +++ b/backend/hermes/app/schemas/customer_import.py @@ -0,0 +1,49 @@ +from typing import Literal + +from pydantic import BaseModel, Field + +ImportMode = Literal["create_only", "update_existing", "upsert"] +ImportAction = Literal["create", "update", "skip", "error"] + + +class CustomerImportIssue(BaseModel): + row: int + field: str = "" + message: str + + +class CustomerImportPreviewRow(BaseModel): + row: int + customer_number: str = "" + company_name: str = "" + action: ImportAction + errors: list[CustomerImportIssue] = Field(default_factory=list) + warnings: list[CustomerImportIssue] = Field(default_factory=list) + + +class CustomerImportSummary(BaseModel): + total_rows: int + valid_rows: int + error_count: int + warning_count: int + duplicate_count: int + create_count: int + update_count: int + skip_count: int + + +class CustomerImportPreviewResponse(BaseModel): + mode: ImportMode + summary: CustomerImportSummary + rows: list[CustomerImportPreviewRow] + errors: list[CustomerImportIssue] + warnings: list[CustomerImportIssue] + duplicates: list[CustomerImportIssue] + + +class CustomerImportCommitResponse(BaseModel): + mode: ImportMode + summary: CustomerImportSummary + created: int + updated: int + skipped: int diff --git a/backend/hermes/app/schemas/rbac.py b/backend/hermes/app/schemas/rbac.py index fae3cf7..a1b8adf 100644 --- a/backend/hermes/app/schemas/rbac.py +++ b/backend/hermes/app/schemas/rbac.py @@ -68,6 +68,8 @@ class RoleResponse(BaseModel): class CurrentUserResponse(BaseModel): id: int + first_name: str + last_name: str username: str email: str role: str diff --git a/backend/hermes/app/services/audit_service.py b/backend/hermes/app/services/audit_service.py index f981a1d..c415107 100644 --- a/backend/hermes/app/services/audit_service.py +++ b/backend/hermes/app/services/audit_service.py @@ -126,9 +126,11 @@ def action_title(action: str) -> str: "customers.create": "Kunde erstellt", "customers.update": "Kunde bearbeitet", "customers.delete": "Kunde gelöscht", + "customers.import": "Kunden importiert", "customer_contacts.create": "Ansprechpartner erstellt", "customer_contacts.update": "Ansprechpartner bearbeitet", "customer_contacts.delete": "Ansprechpartner gelöscht", + "users.initial_admin_bootstrap": "Initialer Administrator erstellt", } return labels.get(action, action) diff --git a/backend/hermes/app/services/customer_import_service.py b/backend/hermes/app/services/customer_import_service.py new file mode 100644 index 0000000..28c414a --- /dev/null +++ b/backend/hermes/app/services/customer_import_service.py @@ -0,0 +1,407 @@ +import csv +import io +import uuid +from dataclasses import dataclass + +from pydantic import EmailStr, HttpUrl, TypeAdapter, ValidationError +from sqlalchemy.orm import Session + +from app.models.customer import Customer, CustomerAddress, CustomerContact +from app.models.user import User +from app.repositories.customer_repository import CustomerRepository +from app.schemas.customer_import import ( + CustomerImportCommitResponse, + CustomerImportIssue, + CustomerImportPreviewResponse, + CustomerImportPreviewRow, + CustomerImportSummary, + ImportMode, +) +from app.services.audit_service import write_audit_log + +CSV_COLUMNS = [ + "customer_number", + "company_name", + "legal_name", + "customer_type", + "status", + "industry", + "website", + "email", + "phone", + "tax_number", + "vat_id", + "notes", + "address_type", + "street", + "postal_code", + "city", + "state", + "country", + "address_is_primary", + "contact_first_name", + "contact_last_name", + "contact_position", + "contact_email", + "contact_phone", + "contact_mobile", + "contact_is_primary", + "contact_notes", +] + +VALID_STATUSES = {"lead", "active", "inactive", "blocked", "archived"} +VALID_TYPES = {"company", "private", "public_sector", "partner", "supplier"} +VALID_ADDRESS_TYPES = {"billing", "shipping", "primary", "other"} +TRUE_VALUES = {"1", "true", "yes", "ja", "j", "x"} +FALSE_VALUES = {"0", "false", "no", "nein", "n", ""} + +email_adapter = TypeAdapter(EmailStr) +url_adapter = TypeAdapter(HttpUrl) + + +@dataclass +class ParsedImportRow: + row_number: int + data: dict[str, str] + action: str + errors: list[CustomerImportIssue] + warnings: list[CustomerImportIssue] + existing_customer: Customer | None = None + + +def get_customer_import_template() -> str: + return ";".join(CSV_COLUMNS) + "\n" + + +def decode_csv(content: bytes) -> str: + try: + return content.decode("utf-8-sig") + except UnicodeDecodeError as exc: + raise ValueError("CSV-Datei muss UTF-8-kodiert sein") from exc + + +def parse_bool(value: str) -> bool: + normalized = value.strip().lower() + if normalized in TRUE_VALUES: + return True + if normalized in FALSE_VALUES: + return False + return False + + +def parse_bool_default(value: str, default: bool = False) -> bool: + if value.strip() == "": + return default + return parse_bool(value) + + +def sniff_dialect(text: str) -> csv.Dialect: + sample = text[:4096] + try: + return csv.Sniffer().sniff(sample, delimiters=",;") + except csv.Error: + return csv.excel + + +def read_csv_rows(content: bytes) -> list[tuple[int, dict[str, str]]]: + text = decode_csv(content) + reader = csv.DictReader(io.StringIO(text), dialect=sniff_dialect(text)) + rows: list[tuple[int, dict[str, str]]] = [] + + for index, row in enumerate(reader, start=2): + normalized = {column: (row.get(column) or "").strip() for column in CSV_COLUMNS} + if not any(normalized.values()): + continue + rows.append((index, normalized)) + + return rows + + +def validate_email(value: str, row_number: int, field: str, errors: list[CustomerImportIssue]) -> str: + if not value: + return "" + try: + return str(email_adapter.validate_python(value)) + except ValidationError: + errors.append(CustomerImportIssue(row=row_number, field=field, message="E-Mail ist ungültig")) + return value + + +def validate_url(value: str, row_number: int, errors: list[CustomerImportIssue]) -> str: + if not value: + return "" + candidate = value if value.startswith(("http://", "https://")) else f"https://{value}" + try: + return str(url_adapter.validate_python(candidate)) + except ValidationError: + errors.append(CustomerImportIssue(row=row_number, field="website", message="Website ist ungültig")) + return value + + +def planned_action(mode: ImportMode, existing_customer: Customer | None) -> str: + if existing_customer is None: + return "create" if mode in {"create_only", "upsert"} else "skip" + if mode == "create_only": + return "skip" + return "update" + + +class CustomerImportService: + @staticmethod + def preview(db: Session, content: bytes, mode: ImportMode) -> CustomerImportPreviewResponse: + rows = CustomerImportService._parse_and_validate(db, content, mode) + return CustomerImportService._build_preview(mode, rows) + + @staticmethod + def commit( + db: Session, + content: bytes, + mode: ImportMode, + *, + actor: User, + request, + ) -> CustomerImportCommitResponse: + rows = CustomerImportService._parse_and_validate(db, content, mode) + preview = CustomerImportService._build_preview(mode, rows) + if preview.summary.valid_rows == 0: + return CustomerImportCommitResponse( + mode=mode, + summary=preview.summary, + created=0, + updated=0, + skipped=preview.summary.skip_count, + ) + + created = 0 + updated = 0 + skipped = 0 + + for row in rows: + if row.errors or row.action == "error": + continue + if row.action == "skip": + skipped += 1 + continue + if row.action == "create": + CustomerImportService._create_customer(db, row.data) + created += 1 + elif row.action == "update" and row.existing_customer is not None: + CustomerImportService._update_customer(db, row.existing_customer, row.data) + updated += 1 + + db.commit() + write_audit_log( + db, + action="customers.import", + entity_type="customers", + actor=actor, + request=request, + metadata={ + "mode": mode, + "total_rows": preview.summary.total_rows, + "created": created, + "updated": updated, + "skipped": skipped, + "errors": preview.summary.error_count, + "warnings": preview.summary.warning_count, + }, + ) + summary = CustomerImportService._build_preview(mode, rows).summary + return CustomerImportCommitResponse( + mode=mode, + summary=summary, + created=created, + updated=updated, + skipped=skipped, + ) + + @staticmethod + def _parse_and_validate(db: Session, content: bytes, mode: ImportMode) -> list[ParsedImportRow]: + csv_rows = read_csv_rows(content) + number_rows: dict[str, int] = {} + duplicates: set[str] = set() + + for row_number, data in csv_rows: + customer_number = data["customer_number"] + if not customer_number: + continue + if customer_number in number_rows: + duplicates.add(customer_number) + else: + number_rows[customer_number] = row_number + + parsed_rows: list[ParsedImportRow] = [] + for row_number, data in csv_rows: + errors: list[CustomerImportIssue] = [] + warnings: list[CustomerImportIssue] = [] + + if not data["company_name"]: + errors.append(CustomerImportIssue(row=row_number, field="company_name", message="Firmenname ist erforderlich")) + + if not data["customer_type"]: + data["customer_type"] = "company" + warnings.append(CustomerImportIssue(row=row_number, field="customer_type", message="Typ fehlt und wird als company importiert")) + elif data["customer_type"] not in VALID_TYPES: + errors.append(CustomerImportIssue(row=row_number, field="customer_type", message="Typ ist ungültig")) + + if not data["status"]: + data["status"] = "active" + warnings.append(CustomerImportIssue(row=row_number, field="status", message="Status fehlt und wird als active importiert")) + elif data["status"] not in VALID_STATUSES: + errors.append(CustomerImportIssue(row=row_number, field="status", message="Status ist ungültig")) + + if not data["address_type"]: + data["address_type"] = "primary" + elif data["address_type"] not in VALID_ADDRESS_TYPES: + errors.append(CustomerImportIssue(row=row_number, field="address_type", message="Adresstyp ist ungültig")) + + data["email"] = validate_email(data["email"], row_number, "email", errors) + data["contact_email"] = validate_email(data["contact_email"], row_number, "contact_email", errors) + data["website"] = validate_url(data["website"], row_number, errors) + + if data["customer_number"] and data["customer_number"] in duplicates: + errors.append(CustomerImportIssue(row=row_number, field="customer_number", message="Kundennummer ist in der CSV mehrfach vorhanden")) + + if not data["customer_number"] and mode == "update_existing": + errors.append(CustomerImportIssue(row=row_number, field="customer_number", message="Kundennummer ist für update_existing erforderlich")) + + if not data["customer_number"]: + warnings.append(CustomerImportIssue(row=row_number, field="customer_number", message="Kundennummer fehlt und wird beim Erstellen generiert")) + + existing_customer = ( + CustomerRepository.get_by_number(db, data["customer_number"]) + if data["customer_number"] + else None + ) + action = planned_action(mode, existing_customer) + if action == "skip": + reason = "Kunde existiert bereits" if existing_customer is not None else "Kunde existiert nicht" + warnings.append(CustomerImportIssue(row=row_number, field="customer_number", message=f"Zeile wird übersprungen: {reason}")) + + if errors: + action = "error" + + parsed_rows.append( + ParsedImportRow( + row_number=row_number, + data=data, + action=action, + errors=errors, + warnings=warnings, + existing_customer=existing_customer, + ) + ) + + return parsed_rows + + @staticmethod + def _build_preview(mode: ImportMode, rows: list[ParsedImportRow]) -> CustomerImportPreviewResponse: + preview_rows = [ + CustomerImportPreviewRow( + row=row.row_number, + customer_number=row.data["customer_number"], + company_name=row.data["company_name"], + action=row.action, + errors=row.errors, + warnings=row.warnings, + ) + for row in rows + ] + errors = [issue for row in rows for issue in row.errors] + warnings = [issue for row in rows for issue in row.warnings] + duplicates = [issue for issue in errors if issue.field == "customer_number" and "mehrfach" in issue.message] + summary = CustomerImportSummary( + total_rows=len(rows), + valid_rows=sum(1 for row in rows if not row.errors and row.action in {"create", "update"}), + error_count=len(errors), + warning_count=len(warnings), + duplicate_count=len(duplicates), + create_count=sum(1 for row in rows if not row.errors and row.action == "create"), + update_count=sum(1 for row in rows if not row.errors and row.action == "update"), + skip_count=sum(1 for row in rows if not row.errors and row.action == "skip"), + ) + return CustomerImportPreviewResponse( + mode=mode, + summary=summary, + rows=preview_rows, + errors=errors, + warnings=warnings, + duplicates=duplicates, + ) + + @staticmethod + def _customer_number(data: dict[str, str]) -> str: + return data["customer_number"] or f"IMP-{uuid.uuid4().hex[:10].upper()}" + + @staticmethod + def _address(data: dict[str, str]) -> CustomerAddress: + return CustomerAddress( + type=data["address_type"], + street=data["street"], + postal_code=data["postal_code"], + city=data["city"], + state=data["state"], + country=data["country"] or "Deutschland", + is_primary=parse_bool_default(data["address_is_primary"], default=True), + ) + + @staticmethod + def _contact(data: dict[str, str]) -> CustomerContact | None: + if not any(data[key] for key in ("contact_first_name", "contact_last_name", "contact_email", "contact_phone", "contact_mobile")): + return None + return CustomerContact( + first_name=data["contact_first_name"], + last_name=data["contact_last_name"], + position=data["contact_position"], + email=data["contact_email"], + phone=data["contact_phone"], + mobile=data["contact_mobile"], + is_primary=parse_bool(data["contact_is_primary"]), + notes=data["contact_notes"], + ) + + @staticmethod + def _create_customer(db: Session, data: dict[str, str]) -> Customer: + customer = Customer( + customer_number=CustomerImportService._customer_number(data), + company_name=data["company_name"], + legal_name=data["legal_name"], + customer_type=data["customer_type"], + status=data["status"], + industry=data["industry"], + website=data["website"], + email=data["email"], + phone=data["phone"], + tax_number=data["tax_number"], + vat_id=data["vat_id"], + notes=data["notes"], + addresses=[CustomerImportService._address(data)], + ) + contact = CustomerImportService._contact(data) + if contact is not None: + customer.contacts = [contact] + db.add(customer) + return customer + + @staticmethod + def _update_customer(db: Session, customer: Customer, data: dict[str, str]) -> Customer: + customer.company_name = data["company_name"] + customer.legal_name = data["legal_name"] + customer.customer_type = data["customer_type"] + customer.status = data["status"] + customer.industry = data["industry"] + customer.website = data["website"] + customer.email = data["email"] + customer.phone = data["phone"] + customer.tax_number = data["tax_number"] + customer.vat_id = data["vat_id"] + customer.notes = data["notes"] + customer.addresses = [CustomerImportService._address(data)] + contact = CustomerImportService._contact(data) + if contact is not None: + if contact.is_primary: + for existing_contact in customer.contacts: + existing_contact.is_primary = False + customer.contacts.append(contact) + db.add(customer) + return customer diff --git a/backend/hermes/app/services/initial_admin_bootstrap.py b/backend/hermes/app/services/initial_admin_bootstrap.py new file mode 100644 index 0000000..98582ba --- /dev/null +++ b/backend/hermes/app/services/initial_admin_bootstrap.py @@ -0,0 +1,82 @@ +import logging + +from pydantic import EmailStr, TypeAdapter, ValidationError +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.core.security import hash_password +from app.models.user import User +from app.repositories.rbac_repository import RbacRepository +from app.services.audit_service import write_audit_log + +logger = logging.getLogger(__name__) + +email_adapter = TypeAdapter(EmailStr) + + +def bootstrap_initial_admin(db: Session) -> None: + active_user_exists = db.scalar(select(User.id).where(User.is_active.is_(True)).limit(1)) + if active_user_exists is not None: + logger.info("initial_admin.skipped_active_user_exists") + return + + username = (settings.initial_admin_username or "").strip() + email = (settings.initial_admin_email or "").strip() + password = settings.initial_admin_password or "" + + if not username or not email or not password: + logger.warning( + "initial_admin.not_configured", + extra={"reason": "missing_required_initial_admin_environment"}, + ) + return + + try: + normalized_email = str(email_adapter.validate_python(email)) + except ValidationError: + logger.warning("initial_admin.invalid_email") + return + + administrator_role = RbacRepository.get_role_by_name(db, "administrator") + if administrator_role is None: + logger.warning("initial_admin.missing_administrator_role") + return + + existing_user = db.scalar(select(User).where((User.username == username) | (User.email == normalized_email))) + if existing_user is not None: + logger.info("initial_admin.skipped_user_already_exists") + return + + admin = User( + first_name=settings.initial_admin_first_name.strip(), + last_name=settings.initial_admin_last_name.strip(), + username=username, + email=normalized_email, + password_hash=hash_password(password), + role="administrator", + role_id=administrator_role.id, + is_active=True, + ) + db.add(admin) + db.commit() + db.refresh(admin) + + write_audit_log( + db, + action="users.initial_admin_bootstrap", + entity_type="users", + entity_id=admin.id, + entity_label=admin.username, + actor_username="system", + after_data={ + "id": admin.id, + "username": admin.username, + "email": admin.email, + "role": admin.role, + "role_id": admin.role_id, + "is_active": admin.is_active, + }, + metadata={"source": "initial_admin_bootstrap"}, + ) + logger.info("initial_admin.created", extra={"user_id": admin.id}) diff --git a/backend/hermes/docker-compose.yml b/backend/hermes/docker-compose.yml index c0816c2..3c5b90e 100644 --- a/backend/hermes/docker-compose.yml +++ b/backend/hermes/docker-compose.yml @@ -5,10 +5,15 @@ services: restart: unless-stopped environment: - DATABASE_URL: postgresql+psycopg://olympus:FsFs03285310!!!@olympus-db:5432/olympus + DATABASE_URL: ${DATABASE_URL} APP_NAME: Hermes API APP_VERSION: 0.1.0 SECRET_KEY: ${SECRET_KEY} + INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-} + INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-} + INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-} + INITIAL_ADMIN_FIRST_NAME: ${INITIAL_ADMIN_FIRST_NAME:-} + INITIAL_ADMIN_LAST_NAME: ${INITIAL_ADMIN_LAST_NAME:-} ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60} JWT_ISSUER: ${JWT_ISSUER:-hermes} LOG_LEVEL: ${LOG_LEVEL:-INFO} diff --git a/docker-compose.yml b/docker-compose.yml index f0768fa..68d7f53 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,11 @@ services: ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60} JWT_ISSUER: ${JWT_ISSUER:-hermes} LOG_LEVEL: ${LOG_LEVEL:-INFO} + INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-} + INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-} + INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-} + INITIAL_ADMIN_FIRST_NAME: ${INITIAL_ADMIN_FIRST_NAME:-} + INITIAL_ADMIN_LAST_NAME: ${INITIAL_ADMIN_LAST_NAME:-} expose: - "8000" diff --git a/frontend/athena/app/api/customers/import/commit/route.ts b/frontend/athena/app/api/customers/import/commit/route.ts new file mode 100644 index 0000000..ca67cb2 --- /dev/null +++ b/frontend/athena/app/api/customers/import/commit/route.ts @@ -0,0 +1,14 @@ +import { NextRequest } from "next/server"; + +import { assertSameOrigin } from "@/lib/server/request-guards"; +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; + +export async function POST(request: NextRequest) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyHermesRequest(request, "/customers/import/commit"); +} diff --git a/frontend/athena/app/api/customers/import/preview/route.ts b/frontend/athena/app/api/customers/import/preview/route.ts new file mode 100644 index 0000000..fcc267f --- /dev/null +++ b/frontend/athena/app/api/customers/import/preview/route.ts @@ -0,0 +1,14 @@ +import { NextRequest } from "next/server"; + +import { assertSameOrigin } from "@/lib/server/request-guards"; +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; + +export async function POST(request: NextRequest) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyHermesRequest(request, "/customers/import/preview"); +} diff --git a/frontend/athena/app/api/customers/import/template/route.ts b/frontend/athena/app/api/customers/import/template/route.ts new file mode 100644 index 0000000..1dd0b8d --- /dev/null +++ b/frontend/athena/app/api/customers/import/template/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from "next/server"; + +const columns = [ + "customer_number", + "company_name", + "legal_name", + "customer_type", + "status", + "industry", + "website", + "email", + "phone", + "tax_number", + "vat_id", + "notes", + "address_type", + "street", + "postal_code", + "city", + "state", + "country", + "address_is_primary", + "contact_first_name", + "contact_last_name", + "contact_position", + "contact_email", + "contact_phone", + "contact_mobile", + "contact_is_primary", + "contact_notes", +]; + +export async function GET() { + return new NextResponse(`${columns.join(";")}\n`, { + headers: { + "Content-Disposition": 'attachment; filename="kundenimport-vorlage.csv"', + "Content-Type": "text/csv; charset=utf-8", + }, + }); +} diff --git a/frontend/athena/app/customers/page.tsx b/frontend/athena/app/customers/page.tsx index 69a9e1a..e7df5ed 100644 --- a/frontend/athena/app/customers/page.tsx +++ b/frontend/athena/app/customers/page.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; -import { Edit, Eye, Plus, Trash2 } from "lucide-react"; +import { Edit, Eye, Plus, Trash2, Upload } from "lucide-react"; import ConfirmDialog from "@/components/common/ConfirmDialog"; import DataTable, { type DataTableColumn } from "@/components/common/DataTable"; @@ -10,11 +10,12 @@ import SearchInput from "@/components/common/SearchInput"; import { useToast } from "@/components/common/ToastProvider"; import { Button, buttonVariants } from "@/components/ui/button"; import CustomerFormDialog from "@/components/customers/CustomerFormDialog"; +import CustomerImportDialog from "@/components/customers/CustomerImportDialog"; import CustomerStatusBadge from "@/components/customers/CustomerStatusBadge"; import { api } from "@/lib/api"; import { hasPermission } from "@/lib/permissions"; import type { CurrentUser } from "@/types/rbac"; -import type { Customer, CustomerPayload, CustomerStatus, CustomerType } from "@/types/customer"; +import type { Customer, CustomerImportCommitResponse, CustomerPayload, CustomerStatus, CustomerType } from "@/types/customer"; const pageSize = 10; @@ -64,6 +65,7 @@ export default function CustomersPage() { const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc"); const [page, setPage] = useState(1); const [formOpen, setFormOpen] = useState(false); + const [importOpen, setImportOpen] = useState(false); const [selectedCustomer, setSelectedCustomer] = useState(null); const [deleteCustomer, setDeleteCustomer] = useState(null); @@ -122,6 +124,7 @@ export default function CustomersPage() { const pageCount = Math.max(1, Math.ceil(filteredCustomers.length / pageSize)); const currentPage = Math.min(page, pageCount); const pageCustomers = filteredCustomers.slice((currentPage - 1) * pageSize, currentPage * pageSize); + const canImportCustomers = hasPermission(currentUser, "customers.create") || hasPermission(currentUser, "customers.update"); const columns: DataTableColumn[] = [ { key: "customer_number", label: "Kundennummer", sortable: true, render: (customer) => customer.customer_number }, @@ -221,6 +224,16 @@ export default function CustomersPage() { } } + async function handleImportCommitted(result: CustomerImportCommitResponse) { + showToast({ + type: "success", + title: "Kundenimport abgeschlossen", + description: `${result.created} erstellt, ${result.updated} aktualisiert, ${result.skipped} übersprungen`, + }); + setLoading(true); + await loadCustomers(); + } + return (
@@ -228,12 +241,20 @@ export default function CustomersPage() {

Kunden

{filteredCustomers.length} von {customers.length} Kunden

- {hasPermission(currentUser, "customers.create") && ( - - )} +
+ {canImportCustomers && ( + + )} + {hasPermission(currentUser, "customers.create") && ( + + )} +
@@ -286,6 +307,13 @@ export default function CustomersPage() { onSubmit={saveCustomer} /> + showToast({ type: "error", title: "Kundenimport fehlgeschlagen", description: message })} + /> + (null); - return ( + useEffect(() => { + let active = true; -
+ api.get("/me") + .then((response) => { + if (active) { + setCurrentUser(response.data); + } + }) + .catch(() => { + if (active) { + setCurrentUser(null); + } + }); -

+ return () => { + active = false; + }; + }, []); - Dashboard + const displayName = useMemo(() => { + if (!currentUser) { + return "Benutzer"; + } -

+ const fullName = `${currentUser.first_name} ${currentUser.last_name}`.trim(); + return fullName || currentUser.username || "Benutzer"; + }, [currentUser]); -
- - - - admin.schubert - - - - - -
- -
- - ); + return ( +
+

Dashboard

+
+ {displayName} + +
+
+ ); } diff --git a/frontend/athena/components/customers/CustomerImportDialog.tsx b/frontend/athena/components/customers/CustomerImportDialog.tsx new file mode 100644 index 0000000..3de6ea7 --- /dev/null +++ b/frontend/athena/components/customers/CustomerImportDialog.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { Download, FileUp, Play, Upload } from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; + +import ImportPreviewTable from "@/components/customers/ImportPreviewTable"; +import ImportSummaryCard from "@/components/customers/ImportSummaryCard"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { api } from "@/lib/api"; +import type { + CustomerImportCommitResponse, + CustomerImportMode, + CustomerImportPreviewResponse, +} from "@/types/customer"; + +type Props = { + open: boolean; + onOpenChange: (open: boolean) => void; + onCommitted: (result: CustomerImportCommitResponse) => void; + onError: (message: string) => void; +}; + +function errorMessage(error: unknown) { + if (typeof error === "object" && error !== null && "response" in error) { + const response = (error as { response?: { data?: { detail?: string } } }).response; + return response?.data?.detail ?? "Import konnte nicht ausgeführt werden"; + } + return "Import konnte nicht ausgeführt werden"; +} + +function buildFormData(file: File, mode: CustomerImportMode) { + const formData = new FormData(); + formData.append("mode", mode); + formData.append("file", file); + return formData; +} + +export default function CustomerImportDialog({ open, onOpenChange, onCommitted, onError }: Props) { + const [file, setFile] = useState(null); + const [mode, setMode] = useState("upsert"); + const [preview, setPreview] = useState(null); + const [pending, setPending] = useState(false); + const [serverError, setServerError] = useState(""); + + async function runPreview() { + if (!file) { + setServerError("Bitte CSV-Datei auswählen"); + return; + } + + setPending(true); + setServerError(""); + try { + const response = await api.post( + "/customers/import/preview", + buildFormData(file, mode), + ); + setPreview(response.data); + } catch (error) { + const message = errorMessage(error); + setServerError(message); + onError(message); + } finally { + setPending(false); + } + } + + async function commitImport() { + if (!file || !preview || preview.summary.valid_rows === 0) { + return; + } + + setPending(true); + setServerError(""); + try { + const response = await api.post( + "/customers/import/commit", + buildFormData(file, mode), + ); + onCommitted(response.data); + setPreview(null); + setFile(null); + onOpenChange(false); + } catch (error) { + const message = errorMessage(error); + setServerError(message); + onError(message); + } finally { + setPending(false); + } + } + + return ( + + + + Kunden importieren + CSV-Datei prüfen und anschließend gültige Zeilen übernehmen. + + +
+
+
+ + { + setFile(event.target.files?.[0] ?? null); + setPreview(null); + setServerError(""); + }} + /> +
+ +
+ + +
+ + + + Vorlage + +
+ + {serverError &&

{serverError}

} + + {preview && ( +
+ + +
+ )} +
+ + + + + + +
+
+ ); +} diff --git a/frontend/athena/components/customers/ImportPreviewTable.tsx b/frontend/athena/components/customers/ImportPreviewTable.tsx new file mode 100644 index 0000000..23f6027 --- /dev/null +++ b/frontend/athena/components/customers/ImportPreviewTable.tsx @@ -0,0 +1,56 @@ +import type { CustomerImportPreviewRow } from "@/types/customer"; + +const actionLabels = { + create: "Create", + update: "Update", + skip: "Skip", + error: "Error", +}; + +const actionClasses = { + create: "bg-emerald-50 text-emerald-700", + update: "bg-blue-50 text-blue-700", + skip: "bg-slate-100 text-slate-600", + error: "bg-red-50 text-red-700", +}; + +function issueText(row: CustomerImportPreviewRow) { + const issues = [...row.errors, ...row.warnings]; + if (issues.length === 0) { + return "-"; + } + return issues.map((issue) => `${issue.field || "Zeile"}: ${issue.message}`).join(" · "); +} + +export default function ImportPreviewTable({ rows }: { rows: CustomerImportPreviewRow[] }) { + return ( +
+ + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + ))} + +
ZeileAktionKundennummerFirmaHinweise
{row.row} + + {actionLabels[row.action]} + + {row.customer_number || "-"}{row.company_name || "-"}{issueText(row)}
+
+ ); +} diff --git a/frontend/athena/components/customers/ImportSummaryCard.tsx b/frontend/athena/components/customers/ImportSummaryCard.tsx new file mode 100644 index 0000000..0bc202d --- /dev/null +++ b/frontend/athena/components/customers/ImportSummaryCard.tsx @@ -0,0 +1,24 @@ +import type { CustomerImportSummary } from "@/types/customer"; + +const summaryItems = [ + ["Gesamt", "total_rows"], + ["Gültig", "valid_rows"], + ["Fehler", "error_count"], + ["Warnungen", "warning_count"], + ["Creates", "create_count"], + ["Updates", "update_count"], + ["Skips", "skip_count"], +] as const; + +export default function ImportSummaryCard({ summary }: { summary: CustomerImportSummary }) { + return ( +
+ {summaryItems.map(([label, key]) => ( +
+

{label}

+

{summary[key]}

+
+ ))} +
+ ); +} diff --git a/frontend/athena/lib/server/hermes-proxy.ts b/frontend/athena/lib/server/hermes-proxy.ts index 7bbc303..68452e4 100644 --- a/frontend/athena/lib/server/hermes-proxy.ts +++ b/frontend/athena/lib/server/hermes-proxy.ts @@ -27,6 +27,8 @@ export async function proxyHermesRequest( } let hermesResponse: Response; + const isMultipart = request.headers.get("content-type")?.includes("multipart/form-data") ?? false; + const hasBody = request.method !== "GET" && request.method !== "DELETE"; try { hermesResponse = await fetch(`${hermesUrl}${path}`, { @@ -34,11 +36,13 @@ export async function proxyHermesRequest( headers: { Authorization: `Bearer ${token}`, Accept: "application/json", - "Content-Type": "application/json", + ...(!isMultipart ? { "Content-Type": "application/json" } : {}), }, - body: request.method === "GET" || request.method === "DELETE" + body: !hasBody ? undefined - : await request.text(), + : isMultipart + ? await request.formData() + : await request.text(), cache: "no-store", }); } catch { diff --git a/frontend/athena/types/customer.ts b/frontend/athena/types/customer.ts index df840a8..2e0d6e8 100644 --- a/frontend/athena/types/customer.ts +++ b/frontend/athena/types/customer.ts @@ -1,6 +1,8 @@ export type CustomerStatus = "lead" | "active" | "inactive" | "blocked" | "archived"; export type CustomerType = "company" | "private" | "public_sector" | "partner" | "supplier"; export type AddressType = "billing" | "shipping" | "primary" | "other"; +export type CustomerImportMode = "create_only" | "update_existing" | "upsert"; +export type CustomerImportAction = "create" | "update" | "skip" | "error"; export interface CustomerAddress { id: number; @@ -87,3 +89,46 @@ export type CustomerContactPayload = { is_primary: boolean; notes: string; }; + +export interface CustomerImportIssue { + row: number; + field: string; + message: string; +} + +export interface CustomerImportPreviewRow { + row: number; + customer_number: string; + company_name: string; + action: CustomerImportAction; + errors: CustomerImportIssue[]; + warnings: CustomerImportIssue[]; +} + +export interface CustomerImportSummary { + total_rows: number; + valid_rows: number; + error_count: number; + warning_count: number; + duplicate_count: number; + create_count: number; + update_count: number; + skip_count: number; +} + +export interface CustomerImportPreviewResponse { + mode: CustomerImportMode; + summary: CustomerImportSummary; + rows: CustomerImportPreviewRow[]; + errors: CustomerImportIssue[]; + warnings: CustomerImportIssue[]; + duplicates: CustomerImportIssue[]; +} + +export interface CustomerImportCommitResponse { + mode: CustomerImportMode; + summary: CustomerImportSummary; + created: number; + updated: number; + skipped: number; +} diff --git a/frontend/athena/types/rbac.ts b/frontend/athena/types/rbac.ts index 1336f29..e3e3520 100644 --- a/frontend/athena/types/rbac.ts +++ b/frontend/athena/types/rbac.ts @@ -30,6 +30,8 @@ export interface RolePayload { export interface CurrentUser { id: number; + first_name: string; + last_name: string; username: string; email: string; role: string;