From 6e7e75f8648ba1b5cb8cc191ca15694cd420a033 Mon Sep 17 00:00:00 2001 From: Schubert Ferenc Date: Sun, 5 Jul 2026 00:34:57 +0200 Subject: [PATCH] feat(repairs): add repair documents --- .gitignore | 5 + ARCHITECTURE.md | 60 +++ README-DEV.md | 13 + ROADMAP.md | 13 +- .../a1c5f9e2d430_extend_repair_documents.py | 58 +++ backend/hermes/app/api/dashboard.py | 1 + backend/hermes/app/api/repairs.py | 119 ++++- backend/hermes/app/models/repair.py | 28 +- .../app/repositories/repair_repository.py | 75 ++- backend/hermes/app/schemas/repair.py | 46 ++ backend/hermes/app/services/audit_service.py | 3 + .../app/services/repair_document_service.py | 225 +++++++++ backend/hermes/app/storage/local.py | 1 + .../documents/[documentId]/download/route.ts | 18 + .../[id]/documents/[documentId]/route.ts | 40 ++ .../app/api/repairs/[id]/documents/route.ts | 14 + .../repairs/[id]/documents/upload/route.ts | 21 + frontend/athena/app/repairs/[id]/page.tsx | 5 + .../repairs/RepairDocumentsSection.tsx | 454 ++++++++++++++++++ frontend/athena/types/repair.ts | 29 ++ ...0efae9368ce987-service-manual-bill-fcc.pdf | Bin 5482476 -> 0 bytes 21 files changed, 1222 insertions(+), 6 deletions(-) create mode 100644 backend/hermes/alembic/versions/a1c5f9e2d430_extend_repair_documents.py create mode 100644 backend/hermes/app/services/repair_document_service.py create mode 100644 frontend/athena/app/api/repairs/[id]/documents/[documentId]/download/route.ts create mode 100644 frontend/athena/app/api/repairs/[id]/documents/[documentId]/route.ts create mode 100644 frontend/athena/app/api/repairs/[id]/documents/route.ts create mode 100644 frontend/athena/app/api/repairs/[id]/documents/upload/route.ts create mode 100644 frontend/athena/components/repairs/RepairDocumentsSection.tsx delete mode 100644 storage/knowledge/documents/1/84dac4e08a654a24a30efae9368ce987-service-manual-bill-fcc.pdf diff --git a/.gitignore b/.gitignore index 4a28a16..b36a9d7 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,11 @@ out/ # =========================== *.log +# =========================== +# Runtime storage +# =========================== +storage/ + # =========================== # Coverage # =========================== diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ce893db..0e0cb43 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -322,6 +322,64 @@ Hermes-Endpunkte: Athena ruft diese Endpunkte ausschliesslich ueber BFF-Routen unter `/api/system-settings/...` auf. Mutierende Requests verwenden den bestehenden Same-Origin-Schutz. Zugriff erfordert `system_settings.manage`, das der Administratorrolle zugewiesen ist. +### Reparaturdokumente + +Ab v0.8.5 verwaltet Olympus Bilder und Dokumente direkt an Reparaturen. Die Fachlogik liegt im Repair-Modul, Dateizugriffe laufen ueber das zentrale Storage Framework. + +Datenmodell `repair_documents`: + +- `repair_id` +- `title` +- `document_type` +- `original_filename` +- `stored_filename` +- `storage_path` +- `mime_type` +- `size_bytes` +- `checksum_sha256` +- `visibility` +- `note` +- `uploaded_by_user_id` +- Zeitstempel + +Dokumenttypen: + +- `device_photo` +- `fault_photo` +- `measurement` +- `estimate` +- `repair_report` +- `shipping` +- `other` + +`visibility` kann `internal` oder `customer` sein. Die Kundensicht ist nur vorbereitet; in v0.8.5 werden Dokumente nicht oeffentlich fuer Kunden ausgeliefert. + +Storage: + +- Namespace: `repairs//documents` +- Erlaubte Uploads: JPG, PNG, WEBP und PDF +- Dateinamen werden normalisiert +- SHA-256 wird gespeichert +- Dateiinhalte werden nicht geloggt +- Storage-Pfade werden nicht in Audit-Metadaten geschrieben +- Browser erhalten Dateien nur ueber Athena-BFF und Hermes-RBAC + +Hermes-Endpunkte: + +- `GET /repairs/{id}/documents` +- `POST /repairs/{id}/documents/upload` +- `GET /repairs/{id}/documents/{document_id}` +- `PUT /repairs/{id}/documents/{document_id}` +- `GET /repairs/{id}/documents/{document_id}/download` +- `DELETE /repairs/{id}/documents/{document_id}` + +RBAC: + +- Liste, Detail und Download erfordern `repairs.read`. +- Upload, Aenderung und Loeschung erfordern `repairs.update`. + +Athena ruft diese Endpunkte ausschliesslich ueber `/api/repairs/[id]/documents...` auf. Mutierende Requests verwenden den Same-Origin-Schutz. + ### Website Repair Intake Hermes stellt `POST /public/repair-intake` fuer eine spaetere serverseitige Website-Anbindung bereit. @@ -741,6 +799,8 @@ Seit v0.7.0 laufen Knowledge-Dateien ueber das zentrale Storage Framework. Knowl Neue Uploads werden im Namespace `knowledge/documents/` gespeichert. Die Datenbank-Metadaten bleiben kompatibel: `knowledge_documents.file_path` enthaelt den Storage-Key fuer neue Dateien oder einen bestehenden Legacy-Pfad fuer alte Dateien. +Reparaturdokumente werden ab v0.8.5 im Namespace `repairs//documents` gespeichert. Zulaessig sind JPG, PNG, WEBP und PDF. PDF-Anzeige bleibt auf Inline-Open/Download begrenzt; ein eigener PDF-Viewer ist ein spaeteres Feature. + Konfiguration: - `STORAGE_PROVIDER`, Default `local` diff --git a/README-DEV.md b/README-DEV.md index 3477ade..165a88c 100644 --- a/README-DEV.md +++ b/README-DEV.md @@ -154,6 +154,10 @@ Athena erreicht Reparaturen ausschliesslich ueber BFF-Routen: - `/api/repairs` - `/api/repairs/[id]` +- `/api/repairs/[id]/documents` +- `/api/repairs/[id]/documents/upload` +- `/api/repairs/[id]/documents/[documentId]` +- `/api/repairs/[id]/documents/[documentId]/download` - `/api/repairs/[id]/status` - `/api/repairs/[id]/history` - `/api/repairs/[id]/public-link` @@ -185,6 +189,15 @@ Benachrichtigungen: - `SMTP_PASSWORD` wird nicht an Athena zurueckgegeben und gehoert nie ins Git oder in Logs. - Verschluesselung at rest ist fuer v0.8.x vorbereitet/geplant; bis dahin bleibt das Passwort serverseitig in `system_settings` und wird in Responses/Audits maskiert. +Reparaturdokumente: + +- Ab v0.8.5 koennen Dokumente und Bilder direkt an Reparaturen gepflegt werden. +- Hermes speichert Uploads ueber `StorageService` unter `repairs//documents`. +- Erlaubte Dateitypen: JPG, PNG, WEBP und PDF. +- Metadaten liegen in `repair_documents`; Dateiinhalte liegen nie im Git oder in `public`. +- `visibility` ist mit `internal` und `customer` vorbereitet. In v0.8.5 gibt es noch keine oeffentliche Kundenanzeige fuer diese Dateien. +- PDF-Dateien koennen inline oder als Download ueber Athena-BFF geoeffnet werden; ein vollstaendiger PDF-Viewer ist ein Folgefeature. + Vorbereitete Website-/Portal-Routen fuer spaeter: - `/status/` diff --git a/ROADMAP.md b/ROADMAP.md index 6699f66..de6753b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,18 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi - Passwort wird nicht an Athena zurueckgegeben - Encryption at rest fuer Secrets bleibt als v0.8.x-Haertung geplant -## v0.8.5 - Kundenportal, geplant +## v0.8.5 - Reparaturdokumente + +- Dokumente und Bilder direkt an Reparaturen verwalten +- Upload ueber Athena-BFF und Hermes StorageService +- Storage-Pfad `repairs//documents` +- Erlaubte Dateitypen JPG, PNG, WEBP und PDF +- Dokumenttypen fuer Geraetefotos, Fehlerbilder, Messbilder, Kostenvoranschlag, Reparaturbericht, Versandbeleg und Sonstiges +- Sichtbarkeit `internal`/`customer` vorbereitet, aber noch keine oeffentliche Kundenanzeige +- Bildvorschau mit Lightbox, PDF inline/download +- Audit Logs fuer Upload, Aenderung und Loeschung + +## v0.8.6 - Kundenportal, geplant - `/portal/login` fuer spaeteren Kundenlogin - Separates Authentifizierungsmodell fuer Kunden diff --git a/backend/hermes/alembic/versions/a1c5f9e2d430_extend_repair_documents.py b/backend/hermes/alembic/versions/a1c5f9e2d430_extend_repair_documents.py new file mode 100644 index 0000000..7091971 --- /dev/null +++ b/backend/hermes/alembic/versions/a1c5f9e2d430_extend_repair_documents.py @@ -0,0 +1,58 @@ +"""extend repair documents + +Revision ID: a1c5f9e2d430 +Revises: f2b8d4e6a910 +Create Date: 2026-07-05 09:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "a1c5f9e2d430" +down_revision: Union[str, Sequence[str], None] = "f2b8d4e6a910" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("repair_documents", sa.Column("original_filename", sa.String(length=255), server_default="", nullable=False)) + op.add_column("repair_documents", sa.Column("stored_filename", sa.String(length=255), server_default="", nullable=False)) + op.alter_column("repair_documents", "storage_path", existing_type=sa.String(length=500), server_default="", nullable=False) + op.add_column("repair_documents", sa.Column("mime_type", sa.String(length=120), server_default="", nullable=False)) + op.add_column("repair_documents", sa.Column("size_bytes", sa.BigInteger(), server_default="0", nullable=False)) + op.add_column("repair_documents", sa.Column("checksum_sha256", sa.String(length=64), server_default="", nullable=False)) + op.add_column("repair_documents", sa.Column("visibility", sa.String(length=40), server_default="internal", nullable=False)) + op.add_column("repair_documents", sa.Column("note", sa.Text(), nullable=True)) + op.add_column("repair_documents", sa.Column("uploaded_by_user_id", sa.Integer(), nullable=True)) + op.add_column("repair_documents", sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False)) + op.create_foreign_key( + op.f("fk_repair_documents_uploaded_by_user_id_users"), + "repair_documents", + "users", + ["uploaded_by_user_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_index(op.f("ix_repair_documents_checksum_sha256"), "repair_documents", ["checksum_sha256"], unique=False) + op.create_index(op.f("ix_repair_documents_uploaded_by_user_id"), "repair_documents", ["uploaded_by_user_id"], unique=False) + op.create_index(op.f("ix_repair_documents_visibility"), "repair_documents", ["visibility"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_repair_documents_visibility"), table_name="repair_documents") + op.drop_index(op.f("ix_repair_documents_uploaded_by_user_id"), table_name="repair_documents") + op.drop_index(op.f("ix_repair_documents_checksum_sha256"), table_name="repair_documents") + op.drop_constraint(op.f("fk_repair_documents_uploaded_by_user_id_users"), "repair_documents", type_="foreignkey") + op.drop_column("repair_documents", "updated_at") + op.drop_column("repair_documents", "uploaded_by_user_id") + op.drop_column("repair_documents", "note") + op.drop_column("repair_documents", "visibility") + op.drop_column("repair_documents", "checksum_sha256") + op.drop_column("repair_documents", "size_bytes") + op.drop_column("repair_documents", "mime_type") + op.alter_column("repair_documents", "storage_path", existing_type=sa.String(length=500), nullable=True, server_default=None) + op.drop_column("repair_documents", "stored_filename") + op.drop_column("repair_documents", "original_filename") diff --git a/backend/hermes/app/api/dashboard.py b/backend/hermes/app/api/dashboard.py index 116fd0f..9f9545d 100644 --- a/backend/hermes/app/api/dashboard.py +++ b/backend/hermes/app/api/dashboard.py @@ -70,6 +70,7 @@ def get_dashboard_summary( MetricCard(label="Statusmails heute", value=RepairRepository.count_status_mails_sent_today(db)), MetricCard(label="Fehlgeschlagene Mails", value=RepairRepository.count_failed_status_mails(db)), MetricCard(label="Offen ohne Kundenmail", value=RepairRepository.count_open_repairs_without_customer_email(db)), + MetricCard(label="Reparaturdokumente", value=RepairRepository.count_documents(db)), ] if "system_settings.manage" in permissions: diff --git a/backend/hermes/app/api/repairs.py b/backend/hermes/app/api/repairs.py index 8e6d820..ffa8f2a 100644 --- a/backend/hermes/app/api/repairs.py +++ b/backend/hermes/app/api/repairs.py @@ -1,4 +1,5 @@ -from fastapi import APIRouter, Depends, Header, HTTPException, Query, status +from fastapi import APIRouter, Depends, File, Form, Header, HTTPException, Query, UploadFile, status +from fastapi.responses import FileResponse from sqlalchemy.orm import Session from starlette.requests import Request from typing import cast @@ -11,6 +12,10 @@ from app.models.user import User from app.repositories.repair_repository import RepairRepository from app.schemas.repair import ( RepairCreate, + RepairDocumentResponse, + RepairDocumentType, + RepairDocumentUpdate, + RepairDocumentVisibility, RepairIntakePayload, RepairIntakeResponse, RepairNotificationEventResponse, @@ -26,6 +31,7 @@ from app.schemas.repair import ( RepairUpdate, ) from app.services.repair_notification_service import RepairNotificationService +from app.services.repair_document_service import RepairDocumentService from app.services.repair_public_link_service import RepairPublicLinkService from app.services.repair_service import RepairService @@ -130,6 +136,117 @@ def get_repair_history( return RepairRepository.get_history(db, repair_id) +@router.get("/repairs/{repair_id}/documents", response_model=list[RepairDocumentResponse]) +def list_repair_documents( + repair_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("repairs.read")), +): + get_repair_or_404(db, repair_id) + return RepairRepository.list_documents(db, repair_id) + + +@router.post("/repairs/{repair_id}/documents/upload", response_model=RepairDocumentResponse, status_code=status.HTTP_201_CREATED) +async def upload_repair_document( + repair_id: int, + request: Request, + file: UploadFile = File(...), + title: str = Form(..., min_length=1, max_length=255), + document_type: RepairDocumentType = Form("other"), + visibility: RepairDocumentVisibility = Form("internal"), + note: str | None = Form(None), + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("repairs.update")), +): + db_repair = get_repair_or_404(db, repair_id) + normalized_title = title.strip() + normalized_note = note.strip() if note else None + if not normalized_title: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Titel ist erforderlich") + return await RepairDocumentService.upload_document( + db, + db_repair, + file=file, + title=normalized_title, + document_type=document_type, + visibility=visibility, + note=normalized_note, + actor=current_user, + request=request, + ) + + +@router.get("/repairs/{repair_id}/documents/{document_id}", response_model=RepairDocumentResponse) +def get_repair_document( + repair_id: int, + document_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("repairs.read")), +): + get_repair_or_404(db, repair_id) + document = RepairRepository.get_document(db, repair_id=repair_id, document_id=document_id) + if document is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden") + return document + + +@router.put("/repairs/{repair_id}/documents/{document_id}", response_model=RepairDocumentResponse) +def update_repair_document( + repair_id: int, + document_id: int, + payload: RepairDocumentUpdate, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("repairs.update")), +): + db_repair = get_repair_or_404(db, repair_id) + document = RepairRepository.get_document(db, repair_id=repair_id, document_id=document_id) + if document is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden") + return RepairDocumentService.update_document(db, db_repair, document, payload, actor=current_user, request=request) + + +@router.get("/repairs/{repair_id}/documents/{document_id}/download") +def download_repair_document( + repair_id: int, + document_id: int, + disposition: str = Query(default="inline", pattern="^(inline|attachment)$"), + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("repairs.read")), +): + get_repair_or_404(db, repair_id) + document = RepairRepository.get_document(db, repair_id=repair_id, document_id=document_id) + if document is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden") + file_path = RepairDocumentService.open_document_file(document) + return FileResponse( + file_path, + media_type=document.mime_type or "application/octet-stream", + headers={ + "Content-Disposition": RepairDocumentService.content_disposition( + document, + mode="attachment" if disposition == "attachment" else "inline", + ), + }, + ) + + +@router.delete("/repairs/{repair_id}/documents/{document_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_repair_document( + repair_id: int, + document_id: int, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("repairs.update")), +): + db_repair = get_repair_or_404(db, repair_id) + document = RepairRepository.get_document(db, repair_id=repair_id, document_id=document_id) + if document is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden") + RepairDocumentService.delete_document(db, db_repair, document, actor=current_user, request=request) + return None + + @router.get("/repairs/{repair_id}/public-link", response_model=RepairPublicLinkResponse) def get_public_link( repair_id: int, diff --git a/backend/hermes/app/models/repair.py b/backend/hermes/app/models/repair.py index a068d2c..53ba9e2 100644 --- a/backend/hermes/app/models/repair.py +++ b/backend/hermes/app/models/repair.py @@ -1,6 +1,6 @@ from datetime import datetime -from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, Text, func +from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Integer, JSON, String, Text, func from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db.database import Base @@ -41,6 +41,7 @@ class Repair(Base): history: Mapped[list["RepairStatusHistory"]] = relationship(back_populates="repair", cascade="all, delete-orphan", lazy="selectin") public_access_tokens: Mapped[list["RepairPublicAccessToken"]] = relationship(back_populates="repair", cascade="all, delete-orphan") notification_events: Mapped[list["RepairNotificationEvent"]] = relationship(back_populates="repair", cascade="all, delete-orphan") + documents: Mapped[list["RepairDocument"]] = relationship(back_populates="repair", cascade="all, delete-orphan") class RepairStatusHistory(Base): @@ -89,10 +90,33 @@ class RepairDocument(Base): id: Mapped[int] = mapped_column(primary_key=True) repair_id: Mapped[int] = mapped_column(ForeignKey("repairs.id", ondelete="CASCADE"), index=True) file_id: Mapped[int | None] = mapped_column(Integer, nullable=True) - storage_path: Mapped[str | None] = mapped_column(String(500), nullable=True) title: Mapped[str] = mapped_column(String(255)) document_type: Mapped[str] = mapped_column(String(80), index=True) + original_filename: Mapped[str] = mapped_column(String(255), default="", server_default="") + stored_filename: Mapped[str] = mapped_column(String(255), default="", server_default="") + storage_path: Mapped[str] = mapped_column(String(500), default="", server_default="") + mime_type: Mapped[str] = mapped_column(String(120), default="", server_default="") + size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0") + checksum_sha256: Mapped[str] = mapped_column(String(64), default="", server_default="", index=True) + visibility: Mapped[str] = mapped_column(String(40), default="internal", server_default="internal", index=True) + note: Mapped[str | None] = mapped_column(Text, nullable=True) + uploaded_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + repair: Mapped[Repair] = relationship(back_populates="documents") + uploaded_by = relationship("User", lazy="joined") + + @property + def uploaded_by_username(self) -> str: + return self.uploaded_by.username if self.uploaded_by is not None else "" + + @property + def uploaded_by_display_name(self) -> str: + if self.uploaded_by is None: + return "" + display_name = f"{self.uploaded_by.first_name} {self.uploaded_by.last_name}".strip() + return display_name or self.uploaded_by.username class RepairPublicAccessToken(Base): diff --git a/backend/hermes/app/repositories/repair_repository.py b/backend/hermes/app/repositories/repair_repository.py index fb115e0..a32e175 100644 --- a/backend/hermes/app/repositories/repair_repository.py +++ b/backend/hermes/app/repositories/repair_repository.py @@ -3,8 +3,8 @@ from datetime import UTC, datetime from sqlalchemy import Select, func, or_, select from sqlalchemy.orm import Session, selectinload -from app.models.repair import Repair, RepairIntakeEvent, RepairNotificationEvent, RepairPublicAccessToken, RepairStatusHistory -from app.schemas.repair import RepairCreate, RepairStatusUpdate, RepairUpdate +from app.models.repair import Repair, RepairDocument, RepairIntakeEvent, RepairNotificationEvent, RepairPublicAccessToken, RepairStatusHistory +from app.schemas.repair import RepairCreate, RepairDocumentUpdate, RepairStatusUpdate, RepairUpdate class RepairRepository: @@ -362,6 +362,77 @@ class RepairRepository: .where((Repair.customer_email == "") | Repair.customer_email.is_(None)) ) or 0 + @staticmethod + def list_documents(db: Session, repair_id: int) -> list[RepairDocument]: + return list( + db.scalars( + select(RepairDocument) + .where(RepairDocument.repair_id == repair_id) + .order_by(RepairDocument.created_at.desc(), RepairDocument.id.desc()) + ) + ) + + @staticmethod + def get_document(db: Session, *, repair_id: int, document_id: int) -> RepairDocument | None: + return db.scalar( + select(RepairDocument) + .where(RepairDocument.repair_id == repair_id) + .where(RepairDocument.id == document_id) + ) + + @staticmethod + def create_document( + db: Session, + *, + repair_id: int, + title: str, + document_type: str, + original_filename: str, + stored_filename: str, + storage_path: str, + mime_type: str, + size_bytes: int, + checksum_sha256: str, + visibility: str, + note: str | None, + uploaded_by_user_id: int | None, + ) -> RepairDocument: + document = RepairDocument( + repair_id=repair_id, + title=title, + document_type=document_type, + original_filename=original_filename, + stored_filename=stored_filename, + storage_path=storage_path, + mime_type=mime_type, + size_bytes=size_bytes, + checksum_sha256=checksum_sha256, + visibility=visibility, + note=note, + uploaded_by_user_id=uploaded_by_user_id, + ) + db.add(document) + db.commit() + db.refresh(document) + return RepairRepository.get_document(db, repair_id=repair_id, document_id=document.id) or document + + @staticmethod + def update_document(db: Session, document: RepairDocument, payload: RepairDocumentUpdate) -> RepairDocument: + for key, value in payload.model_dump().items(): + setattr(document, key, value) + db.commit() + db.refresh(document) + return RepairRepository.get_document(db, repair_id=document.repair_id, document_id=document.id) or document + + @staticmethod + def delete_document(db: Session, document: RepairDocument) -> None: + db.delete(document) + db.commit() + + @staticmethod + def count_documents(db: Session) -> int: + return db.scalar(select(func.count(RepairDocument.id))) or 0 + @staticmethod def _payload_data(payload: RepairCreate | RepairUpdate) -> dict: data = payload.model_dump() diff --git a/backend/hermes/app/schemas/repair.py b/backend/hermes/app/schemas/repair.py index fcb3cfa..cb55b81 100644 --- a/backend/hermes/app/schemas/repair.py +++ b/backend/hermes/app/schemas/repair.py @@ -19,6 +19,16 @@ RepairStatus = Literal[ ] RepairPriority = Literal["low", "normal", "high", "urgent"] RepairSource = Literal["manual", "website", "customer_portal", "email", "phone"] +RepairDocumentType = Literal[ + "device_photo", + "fault_photo", + "measurement", + "estimate", + "repair_report", + "shipping", + "other", +] +RepairDocumentVisibility = Literal["internal", "customer"] def normalize_text(value: object) -> str: @@ -222,3 +232,39 @@ class RepairNotificationEventResponse(BaseModel): class RepairNotificationOverviewResponse(BaseModel): templates: list[RepairNotificationTemplateResponse] events: list[RepairNotificationEventResponse] + + +class RepairDocumentBase(BaseModel): + title: str = Field(min_length=1, max_length=255) + document_type: RepairDocumentType = "other" + visibility: RepairDocumentVisibility = "internal" + note: str | None = None + + @field_validator("title", "note", mode="before") + @classmethod + def normalize_document_strings(cls, value: object) -> str | None: + if value is None: + return None + return normalize_text(value) + + +class RepairDocumentUpdate(RepairDocumentBase): + pass + + +class RepairDocumentResponse(RepairDocumentBase): + id: int + repair_id: int + original_filename: str + stored_filename: str + storage_path: str + mime_type: str + size_bytes: int + checksum_sha256: str + uploaded_by_user_id: int | None + uploaded_by_username: str = "" + uploaded_by_display_name: str = "" + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/hermes/app/services/audit_service.py b/backend/hermes/app/services/audit_service.py index d38a335..0ff681a 100644 --- a/backend/hermes/app/services/audit_service.py +++ b/backend/hermes/app/services/audit_service.py @@ -169,6 +169,9 @@ def action_title(action: str) -> str: "repairs.public_link.regenerate": "Reparatur-Statuslink erneut erstellt", "repairs.status_mail.sent": "Statusmail versendet", "repairs.status_mail.failed": "Statusmail fehlgeschlagen", + "repairs.documents.upload": "Reparaturdokument hochgeladen", + "repairs.documents.update": "Reparaturdokument geändert", + "repairs.documents.delete": "Reparaturdokument gelöscht", "system_settings.smtp.update": "SMTP-Konfiguration geändert", "system_settings.smtp.test_sent": "SMTP-Testmail versendet", "system_settings.smtp.test_failed": "SMTP-Testmail fehlgeschlagen", diff --git a/backend/hermes/app/services/repair_document_service.py b/backend/hermes/app/services/repair_document_service.py new file mode 100644 index 0000000..b0e9f2c --- /dev/null +++ b/backend/hermes/app/services/repair_document_service.py @@ -0,0 +1,225 @@ +import mimetypes +from pathlib import Path +from typing import Literal +from urllib.parse import quote + +from fastapi import HTTPException, UploadFile, status +from sqlalchemy.orm import Session +from starlette.requests import Request + +from app.models.repair import Repair, RepairDocument +from app.models.user import User +from app.repositories.repair_repository import RepairRepository +from app.schemas.repair import RepairDocumentUpdate +from app.services.audit_service import write_audit_log +from app.storage import get_storage_service +from app.storage.exceptions import StorageFileNotFoundError, StorageValidationError + +ALLOWED_REPAIR_DOCUMENT_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".pdf"} +ALLOWED_REPAIR_DOCUMENT_MIME_TYPES = { + "image/jpeg", + "image/png", + "image/webp", + "application/pdf", +} + + +DOCUMENT_TYPE_LABELS = { + "device_photo": "Gerätefoto", + "fault_photo": "Fehlerbild", + "measurement": "Messbild", + "estimate": "Kostenvoranschlag", + "repair_report": "Reparaturbericht", + "shipping": "Versandbeleg", + "other": "Dokument", +} + + +def storage_validation_error(exc: StorageValidationError) -> HTTPException: + detail = str(exc) or "Ungültige Datei" + status_code = status.HTTP_413_REQUEST_ENTITY_TOO_LARGE if "groß" in detail else status.HTTP_400_BAD_REQUEST + return HTTPException(status_code=status_code, detail=detail) + + +def _repair_label(repair: Repair) -> str: + return f"{repair.repair_number} · {repair.customer_name}" + + +def _document_label(document: RepairDocument) -> str: + return f"{document.title} · {document.original_filename}" + + +def _audit_document_data(document: RepairDocument) -> dict: + return { + "id": document.id, + "repair_id": document.repair_id, + "title": document.title, + "document_type": document.document_type, + "original_filename": document.original_filename, + "mime_type": document.mime_type, + "size_bytes": document.size_bytes, + "visibility": document.visibility, + "uploaded_by_user_id": document.uploaded_by_user_id, + "created_at": document.created_at, + "updated_at": document.updated_at, + } + + +class RepairDocumentService: + @staticmethod + async def upload_document( + db: Session, + repair: Repair, + *, + file: UploadFile, + title: str, + document_type: str, + visibility: str, + note: str | None, + actor: User, + request: Request, + ) -> RepairDocument: + storage_service = get_storage_service() + max_bytes = storage_service.max_upload_mb * 1024 * 1024 + content = await file.read(max_bytes + 1) + original_filename = file.filename or "" + mime_type = RepairDocumentService._resolve_mime_type(original_filename, file.content_type) + + RepairDocumentService._validate_repair_document_type(original_filename, mime_type) + + try: + metadata = storage_service.save_file( + namespace=f"repairs/{repair.id}/documents", + content=content, + original_filename=original_filename, + mime_type=mime_type, + ) + except StorageValidationError as exc: + raise storage_validation_error(exc) from exc + + try: + document = RepairRepository.create_document( + db, + repair_id=repair.id, + title=title, + document_type=document_type, + original_filename=metadata.original_filename, + stored_filename=metadata.stored_filename, + storage_path=metadata.storage_key, + mime_type=metadata.mime_type, + size_bytes=metadata.size, + checksum_sha256=metadata.checksum_sha256, + visibility=visibility, + note=note, + uploaded_by_user_id=actor.id, + ) + except Exception: + storage_service.delete_file(metadata.storage_key) + raise + + write_audit_log( + db, + action="repairs.documents.upload", + entity_type="repair_documents", + entity_id=document.id, + entity_label=_document_label(document), + actor=actor, + request=request, + metadata={ + "repair_id": repair.id, + "repair_number": repair.repair_number, + "document_type": document.document_type, + "visibility": document.visibility, + "mime_type": document.mime_type, + "size_bytes": document.size_bytes, + }, + ) + return document + + @staticmethod + def update_document( + db: Session, + repair: Repair, + document: RepairDocument, + payload: RepairDocumentUpdate, + *, + actor: User, + request: Request, + ) -> RepairDocument: + before_data = _audit_document_data(document) + updated = RepairRepository.update_document(db, document, payload) + write_audit_log( + db, + action="repairs.documents.update", + entity_type="repair_documents", + entity_id=updated.id, + entity_label=_document_label(updated), + actor=actor, + request=request, + before_data=before_data, + after_data=_audit_document_data(updated), + metadata={"repair_id": repair.id, "repair_number": repair.repair_number}, + ) + return updated + + @staticmethod + def delete_document( + db: Session, + repair: Repair, + document: RepairDocument, + *, + actor: User, + request: Request, + ) -> None: + before_data = _audit_document_data(document) + storage_path = document.storage_path + label = _document_label(document) + try: + get_storage_service().delete_file(storage_path) + except StorageValidationError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Ungültiger Dateipfad") from exc + + RepairRepository.delete_document(db, document) + write_audit_log( + db, + action="repairs.documents.delete", + entity_type="repair_documents", + entity_id=document.id, + entity_label=label, + actor=actor, + request=request, + before_data=before_data, + metadata={"repair_id": repair.id, "repair_number": repair.repair_number}, + ) + + @staticmethod + def open_document_file(document: RepairDocument) -> Path: + if not document.storage_path: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Datei nicht gefunden") + try: + return get_storage_service().open_file(document.storage_path) + except StorageFileNotFoundError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Datei nicht gefunden") from exc + except StorageValidationError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Ungültiger Dateipfad") from exc + + @staticmethod + def content_disposition(document: RepairDocument, *, mode: Literal["inline", "attachment"]) -> str: + filename = document.original_filename or document.stored_filename or "reparatur-dokument" + safe_filename = filename.replace('"', "") + encoded_filename = quote(filename) + return f'{mode}; filename="{safe_filename}"; filename*=UTF-8\'\'{encoded_filename}' + + @staticmethod + def _resolve_mime_type(original_filename: str, content_type: str | None) -> str: + if content_type and content_type != "application/octet-stream": + return content_type + return mimetypes.guess_type(original_filename)[0] or "application/octet-stream" + + @staticmethod + def _validate_repair_document_type(original_filename: str, mime_type: str) -> None: + extension = Path(original_filename or "").suffix.lower() + if extension not in ALLOWED_REPAIR_DOCUMENT_EXTENSIONS: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Dateityp ist nicht erlaubt") + if mime_type not in ALLOWED_REPAIR_DOCUMENT_MIME_TYPES: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="MIME-Type ist nicht erlaubt") diff --git a/backend/hermes/app/storage/local.py b/backend/hermes/app/storage/local.py index 9267a12..aaab81c 100644 --- a/backend/hermes/app/storage/local.py +++ b/backend/hermes/app/storage/local.py @@ -87,6 +87,7 @@ class LocalDiskStorageProvider(StorageProvider): "customers", "projects", "tickets", + "repairs", "imports", "temp", ]: diff --git a/frontend/athena/app/api/repairs/[id]/documents/[documentId]/download/route.ts b/frontend/athena/app/api/repairs/[id]/documents/[documentId]/download/route.ts new file mode 100644 index 0000000..b36d87f --- /dev/null +++ b/frontend/athena/app/api/repairs/[id]/documents/[documentId]/download/route.ts @@ -0,0 +1,18 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesStreamRequest } from "@/lib/server/hermes-proxy"; + +type Params = { + params: Promise<{ + id: string; + documentId: string; + }>; +}; + +export async function GET(request: NextRequest, { params }: Params) { + const { id, documentId } = await params; + return proxyHermesStreamRequest( + request, + `/repairs/${id}/documents/${documentId}/download${request.nextUrl.search}`, + ); +} diff --git a/frontend/athena/app/api/repairs/[id]/documents/[documentId]/route.ts b/frontend/athena/app/api/repairs/[id]/documents/[documentId]/route.ts new file mode 100644 index 0000000..734fa67 --- /dev/null +++ b/frontend/athena/app/api/repairs/[id]/documents/[documentId]/route.ts @@ -0,0 +1,40 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +type Params = { + params: Promise<{ + id: string; + documentId: string; + }>; +}; + +async function proxyDocumentRequest(request: NextRequest, { params }: Params) { + const { id, documentId } = await params; + return proxyHermesRequest(request, `/repairs/${id}/documents/${documentId}`); +} + +export async function GET(request: NextRequest, context: Params) { + return proxyDocumentRequest(request, context); +} + +export async function PUT(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyDocumentRequest(request, context); +} + +export async function DELETE(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyDocumentRequest(request, context); +} diff --git a/frontend/athena/app/api/repairs/[id]/documents/route.ts b/frontend/athena/app/api/repairs/[id]/documents/route.ts new file mode 100644 index 0000000..1db71c3 --- /dev/null +++ b/frontend/athena/app/api/repairs/[id]/documents/route.ts @@ -0,0 +1,14 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; + +type Params = { + params: Promise<{ + id: string; + }>; +}; + +export async function GET(request: NextRequest, { params }: Params) { + const { id } = await params; + return proxyHermesRequest(request, `/repairs/${id}/documents`); +} diff --git a/frontend/athena/app/api/repairs/[id]/documents/upload/route.ts b/frontend/athena/app/api/repairs/[id]/documents/upload/route.ts new file mode 100644 index 0000000..90cd43d --- /dev/null +++ b/frontend/athena/app/api/repairs/[id]/documents/upload/route.ts @@ -0,0 +1,21 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +type Params = { + params: Promise<{ + id: string; + }>; +}; + +export async function POST(request: NextRequest, { params }: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + const { id } = await params; + return proxyHermesRequest(request, `/repairs/${id}/documents/upload`); +} diff --git a/frontend/athena/app/repairs/[id]/page.tsx b/frontend/athena/app/repairs/[id]/page.tsx index ebff51b..9bb4f9a 100644 --- a/frontend/athena/app/repairs/[id]/page.tsx +++ b/frontend/athena/app/repairs/[id]/page.tsx @@ -6,6 +6,7 @@ import { ArrowLeft, CheckCircle2, Copy, Edit, Link2, Mail, Send, ShieldCheck, Wr import DetailSection from "@/components/common/DetailSection"; import { useToast } from "@/components/common/ToastProvider"; +import RepairDocumentsSection from "@/components/repairs/RepairDocumentsSection"; import RepairFormDialog from "@/components/repairs/RepairFormDialog"; import { RepairPriorityBadge, RepairStatusBadge, statusLabels } from "@/components/repairs/RepairStatusBadge"; import RepairStatusDialog from "@/components/repairs/RepairStatusDialog"; @@ -426,6 +427,10 @@ export default function RepairDetailPage({ params }: Params) { })()} + + + +

Reparaturaktionen werden in den Audit Logs erfasst und erscheinen im Activity Feed, wenn `repairs.read` vorhanden ist.

Audit Logs öffnen diff --git a/frontend/athena/components/repairs/RepairDocumentsSection.tsx b/frontend/athena/components/repairs/RepairDocumentsSection.tsx new file mode 100644 index 0000000..f2a6265 --- /dev/null +++ b/frontend/athena/components/repairs/RepairDocumentsSection.tsx @@ -0,0 +1,454 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import { + Download, + Eye, + FileText, + ImageIcon, + Plus, + Trash2, + Upload, + X, + ZoomIn, + ZoomOut, +} from "lucide-react"; + +import ConfirmDialog from "@/components/common/ConfirmDialog"; +import { useToast } from "@/components/common/ToastProvider"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { api } from "@/lib/api"; +import type { RepairDocument, RepairDocumentType, RepairDocumentVisibility } from "@/types/repair"; + +const documentTypeLabels: Record = { + device_photo: "Gerätefoto", + fault_photo: "Fehlerbild", + measurement: "Messbild", + estimate: "Kostenvoranschlag", + repair_report: "Reparaturbericht", + shipping: "Versandbeleg", + other: "Sonstiges", +}; + +const documentTypes = Object.entries(documentTypeLabels) as Array<[RepairDocumentType, string]>; + +function getErrorMessage(error: unknown) { + if (typeof error === "object" && error !== null && "response" in error) { + const response = (error as { response?: { data?: { detail?: string; message?: string } } }).response; + return response?.data?.detail ?? response?.data?.message ?? "Aktion konnte nicht abgeschlossen werden"; + } + return "Aktion konnte nicht abgeschlossen werden"; +} + +function formatDate(value: string) { + return new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeStyle: "short" }).format(new Date(value)); +} + +function formatSize(value: number) { + if (value < 1024) return `${value} B`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; + return `${(value / 1024 / 1024).toFixed(1)} MB`; +} + +function isImage(document: RepairDocument) { + return document.mime_type.startsWith("image/"); +} + +function isPdf(document: RepairDocument) { + return document.mime_type === "application/pdf"; +} + +function downloadUrl(repairId: number, documentId: number, disposition: "inline" | "attachment" = "inline") { + return `/api/repairs/${repairId}/documents/${documentId}/download?disposition=${disposition}`; +} + +type Props = { + repairId: number; + canUpdate: boolean; +}; + +export default function RepairDocumentsSection({ repairId, canUpdate }: Props) { + const { showToast } = useToast(); + const fileInputRef = useRef(null); + const [documents, setDocuments] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [uploadOpen, setUploadOpen] = useState(false); + const [title, setTitle] = useState(""); + const [documentType, setDocumentType] = useState("device_photo"); + const [visibility, setVisibility] = useState("internal"); + const [note, setNote] = useState(""); + const [selectedFile, setSelectedFile] = useState(null); + const [uploading, setUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); + const [previewDocument, setPreviewDocument] = useState(null); + const [zoom, setZoom] = useState(1); + + const loadDocuments = useCallback(async () => { + setLoading(true); + setError(""); + try { + const response = await api.get(`/repairs/${repairId}/documents`); + setDocuments(response.data); + } catch (err) { + setError(getErrorMessage(err)); + } finally { + setLoading(false); + } + }, [repairId]); + + useEffect(() => { + queueMicrotask(() => { + void loadDocuments(); + }); + }, [loadDocuments]); + + const imageDocuments = useMemo(() => documents.filter(isImage), [documents]); + + useEffect(() => { + if (!previewDocument) return; + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + setPreviewDocument(null); + } + } + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [previewDocument]); + + function resetUploadForm() { + setTitle(""); + setDocumentType("device_photo"); + setVisibility("internal"); + setNote(""); + setSelectedFile(null); + setUploadProgress(0); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + } + + async function uploadDocument() { + if (!selectedFile) { + showToast({ type: "error", title: "Keine Datei ausgewählt" }); + return; + } + + const formData = new FormData(); + formData.append("file", selectedFile); + formData.append("title", title.trim() || selectedFile.name); + formData.append("document_type", documentType); + formData.append("visibility", visibility); + formData.append("note", note.trim()); + + setUploading(true); + setUploadProgress(0); + try { + await api.post(`/repairs/${repairId}/documents/upload`, formData, { + onUploadProgress: (event) => { + if (event.total) { + setUploadProgress(Math.round((event.loaded / event.total) * 100)); + } + }, + }); + await loadDocuments(); + resetUploadForm(); + setUploadOpen(false); + showToast({ type: "success", title: "Dokument hochgeladen" }); + } catch (err) { + showToast({ + type: "error", + title: "Upload fehlgeschlagen", + description: getErrorMessage(err), + }); + } finally { + setUploading(false); + } + } + + async function deleteDocument() { + if (!deleteTarget) return; + setDeleting(true); + try { + await api.delete(`/repairs/${repairId}/documents/${deleteTarget.id}`); + setDocuments((current) => current.filter((item) => item.id !== deleteTarget.id)); + setDeleteTarget(null); + showToast({ type: "success", title: "Dokument gelöscht" }); + } catch (err) { + showToast({ + type: "error", + title: "Dokument konnte nicht gelöscht werden", + description: getErrorMessage(err), + }); + } finally { + setDeleting(false); + } + } + + function openDocument(document: RepairDocument) { + if (isImage(document)) { + setPreviewDocument(document); + setZoom(1); + return; + } + window.open(downloadUrl(repairId, document.id, "inline"), "_blank", "noopener,noreferrer"); + } + + if (loading) { + return
Dokumente werden geladen...
; + } + + if (error) { + return
{error}
; + } + + return ( +
+
+

+ {documents.length} Datei{documents.length === 1 ? "" : "en"} gespeichert +

+ {canUpdate && ( + + )} +
+ + {documents.length === 0 ? ( +
+

Noch keine Dokumente oder Bilder vorhanden.

+

+ Gerätefotos, Messbilder, PDF-Berichte und Belege werden sicher im Olympus Storage gespeichert. +

+ {canUpdate && ( +
+ +
+ )} +
+ ) : ( +
+ {documents.map((document) => ( +
+ + +
+
+
+ {isImage(document) ? : } +
+

{document.title}

+

{document.original_filename}

+
+
+ {document.note &&

{document.note}

} +
+ +
+ + + + + + +
+ +
+ + + + Download + + {canUpdate && ( + + )} +
+
+
+ ))} +
+ )} + + { + setUploadOpen(open); + if (!open && !uploading) resetUploadForm(); + }}> + + + Dokument hochladen + JPG, PNG, WEBP und PDF werden im Reparatur-Storage abgelegt. + + +
+ + { + const file = event.target.files?.[0] ?? null; + setSelectedFile(file); + if (file && !title) setTitle(file.name); + }} + /> + + + setTitle(event.target.value)} /> + +
+ + + + + + +
+ +