feat(repairs): add repair documents

This commit is contained in:
Schubert Ferenc 2026-07-05 00:34:57 +02:00
parent 884a20e043
commit 6e7e75f864
21 changed files with 1222 additions and 6 deletions

5
.gitignore vendored
View file

@ -39,6 +39,11 @@ out/
# ===========================
*.log
# ===========================
# Runtime storage
# ===========================
storage/
# ===========================
# Coverage
# ===========================

View file

@ -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/<repair_id>/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/<manufacturer_id>` 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/<repair_id>/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`

View file

@ -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/<repair_id>/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/<token>`

View file

@ -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/<repair_id>/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

View file

@ -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")

View file

@ -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:

View file

@ -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,

View file

@ -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):

View file

@ -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()

View file

@ -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)

View file

@ -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",

View file

@ -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")

View file

@ -87,6 +87,7 @@ class LocalDiskStorageProvider(StorageProvider):
"customers",
"projects",
"tickets",
"repairs",
"imports",
"temp",
]:

View file

@ -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}`,
);
}

View file

@ -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);
}

View file

@ -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`);
}

View file

@ -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`);
}

View file

@ -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) {
})()}
</DetailSection>
<DetailSection title="Dokumente & Bilder">
<RepairDocumentsSection repairId={repair.id} canUpdate={canUpdate} />
</DetailSection>
<DetailSection title="Audit und Aktivität vorbereitet">
<p className="text-sm text-slate-500">Reparaturaktionen werden in den Audit Logs erfasst und erscheinen im Activity Feed, wenn `repairs.read` vorhanden ist.</p>
<Link href="/audit-logs" className={buttonVariants({ variant: "outline", size: "sm", className: "mt-4" })}>Audit Logs öffnen</Link>

View file

@ -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<RepairDocumentType, string> = {
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<HTMLInputElement | null>(null);
const [documents, setDocuments] = useState<RepairDocument[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [uploadOpen, setUploadOpen] = useState(false);
const [title, setTitle] = useState("");
const [documentType, setDocumentType] = useState<RepairDocumentType>("device_photo");
const [visibility, setVisibility] = useState<RepairDocumentVisibility>("internal");
const [note, setNote] = useState("");
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [deleteTarget, setDeleteTarget] = useState<RepairDocument | null>(null);
const [deleting, setDeleting] = useState(false);
const [previewDocument, setPreviewDocument] = useState<RepairDocument | null>(null);
const [zoom, setZoom] = useState(1);
const loadDocuments = useCallback(async () => {
setLoading(true);
setError("");
try {
const response = await api.get<RepairDocument[]>(`/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<RepairDocument>(`/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 <div className="rounded-lg border bg-slate-50 p-5 text-sm text-slate-500">Dokumente werden geladen...</div>;
}
if (error) {
return <div className="rounded-lg border bg-red-50 p-5 text-sm text-red-700">{error}</div>;
}
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-slate-500">
{documents.length} Datei{documents.length === 1 ? "" : "en"} gespeichert
</p>
{canUpdate && (
<Button type="button" onClick={() => setUploadOpen(true)}>
<Plus />
Dokument hochladen
</Button>
)}
</div>
{documents.length === 0 ? (
<div className="rounded-lg border border-dashed bg-slate-50 p-8 text-center">
<h3 className="text-lg font-semibold text-slate-950">Noch keine Dokumente oder Bilder vorhanden.</h3>
<p className="mx-auto mt-2 max-w-xl text-sm text-slate-500">
Gerätefotos, Messbilder, PDF-Berichte und Belege werden sicher im Olympus Storage gespeichert.
</p>
{canUpdate && (
<div className="mt-5 flex justify-center">
<Button type="button" onClick={() => setUploadOpen(true)}><Upload />Upload starten</Button>
</div>
)}
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{documents.map((document) => (
<article key={document.id} className="overflow-hidden rounded-lg border bg-white">
<button
type="button"
className="flex aspect-video w-full items-center justify-center bg-slate-100 text-slate-500"
onClick={() => openDocument(document)}
aria-label={`${document.title} öffnen`}
>
{isImage(document) ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={downloadUrl(repairId, document.id)} alt={document.title} className="h-full w-full object-cover" />
) : (
<div className="flex flex-col items-center gap-2">
<FileText className="h-10 w-10" />
<span className="text-xs font-medium">{isPdf(document) ? "PDF" : document.mime_type}</span>
</div>
)}
</button>
<div className="space-y-4 p-4">
<div>
<div className="flex items-start gap-2">
{isImage(document) ? <ImageIcon className="mt-0.5 h-4 w-4 shrink-0 text-slate-500" /> : <FileText className="mt-0.5 h-4 w-4 shrink-0 text-slate-500" />}
<div className="min-w-0">
<h3 className="truncate font-semibold text-slate-950">{document.title}</h3>
<p className="mt-1 truncate text-xs text-slate-500">{document.original_filename}</p>
</div>
</div>
{document.note && <p className="mt-3 line-clamp-2 text-sm text-slate-600">{document.note}</p>}
</div>
<dl className="grid grid-cols-2 gap-3 text-xs">
<Meta label="Typ" value={documentTypeLabels[document.document_type]} />
<Meta label="Größe" value={formatSize(document.size_bytes)} />
<Meta label="MIME" value={document.mime_type} />
<Meta label="Sichtbarkeit" value={document.visibility === "customer" ? "Kunde" : "Intern"} />
<Meta label="Upload" value={formatDate(document.created_at)} />
<Meta label="Von" value={document.uploaded_by_display_name || document.uploaded_by_username || "System"} />
</dl>
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" variant="outline" onClick={() => openDocument(document)}>
<Eye />
Vorschau
</Button>
<a href={downloadUrl(repairId, document.id, "attachment")} className={buttonVariants({ variant: "outline", size: "sm" })}>
<Download />
Download
</a>
{canUpdate && (
<Button type="button" size="sm" variant="destructive" onClick={() => setDeleteTarget(document)}>
<Trash2 />
Löschen
</Button>
)}
</div>
</div>
</article>
))}
</div>
)}
<Dialog open={uploadOpen} onOpenChange={(open) => {
setUploadOpen(open);
if (!open && !uploading) resetUploadForm();
}}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Dokument hochladen</DialogTitle>
<DialogDescription>JPG, PNG, WEBP und PDF werden im Reparatur-Storage abgelegt.</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<Field label="Datei">
<Input
ref={fileInputRef}
type="file"
accept=".jpg,.jpeg,.png,.webp,.pdf,image/jpeg,image/png,image/webp,application/pdf"
onChange={(event) => {
const file = event.target.files?.[0] ?? null;
setSelectedFile(file);
if (file && !title) setTitle(file.name);
}}
/>
</Field>
<Field label="Titel">
<Input value={title} onChange={(event) => setTitle(event.target.value)} />
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Dokumenttyp">
<select
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
value={documentType}
onChange={(event) => setDocumentType(event.target.value as RepairDocumentType)}
>
{documentTypes.map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</Field>
<Field label="Sichtbarkeit">
<select
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
value={visibility}
onChange={(event) => setVisibility(event.target.value as RepairDocumentVisibility)}
>
<option value="internal">Intern</option>
<option value="customer">Kunde vorbereitet</option>
</select>
</Field>
</div>
<Field label="Notiz">
<textarea
className="min-h-24 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
value={note}
onChange={(event) => setNote(event.target.value)}
/>
</Field>
{uploading && (
<div className="rounded-lg border bg-slate-50 p-3">
<div className="h-2 overflow-hidden rounded-full bg-slate-200">
<div className="h-full bg-slate-900 transition-all" style={{ width: `${uploadProgress}%` }} />
</div>
<p className="mt-2 text-xs text-slate-500">{uploadProgress}% hochgeladen</p>
</div>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setUploadOpen(false)} disabled={uploading}>
Abbrechen
</Button>
<Button type="button" onClick={() => void uploadDocument()} disabled={uploading || !selectedFile}>
<Upload />
{uploading ? "Lädt hoch..." : "Upload"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog
open={Boolean(deleteTarget)}
title="Dokument löschen?"
description="Die Datei wird aus dem Storage entfernt und kann nicht wiederhergestellt werden."
pending={deleting}
onOpenChange={(open) => {
if (!open && !deleting) setDeleteTarget(null);
}}
onConfirm={() => void deleteDocument()}
>
{deleteTarget && (
<p className="text-sm text-slate-600">
{deleteTarget.title}
<br />
<span className="text-slate-500">{deleteTarget.original_filename}</span>
</p>
)}
</ConfirmDialog>
{previewDocument && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4" role="dialog" aria-modal="true">
<div className="absolute right-4 top-4 flex gap-2">
<Button type="button" variant="secondary" size="icon" onClick={() => setZoom((current) => Math.max(0.5, current - 0.25))} aria-label="Verkleinern">
<ZoomOut />
</Button>
<Button type="button" variant="secondary" size="icon" onClick={() => setZoom((current) => Math.min(3, current + 0.25))} aria-label="Vergrößern">
<ZoomIn />
</Button>
<Button type="button" variant="secondary" size="icon" onClick={() => setPreviewDocument(null)} aria-label="Vorschau schließen">
<X />
</Button>
</div>
<button type="button" className="absolute inset-0 cursor-default" onClick={() => setPreviewDocument(null)} aria-label="Vorschau schließen" />
<div className="relative max-h-[90vh] max-w-[90vw] overflow-auto">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={downloadUrl(repairId, previewDocument.id)}
alt={previewDocument.title}
className="relative block max-h-[85vh] max-w-[85vw] object-contain transition-transform"
style={{ transform: `scale(${zoom})` }}
/>
</div>
<p className="absolute bottom-4 left-1/2 max-w-[80vw] -translate-x-1/2 truncate rounded bg-black/60 px-3 py-2 text-sm text-white">
{previewDocument.title} · {imageDocuments.findIndex((item) => item.id === previewDocument.id) + 1}/{imageDocuments.length}
</p>
</div>
)}
</div>
);
}
function Meta({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="font-medium uppercase tracking-wide text-slate-500">{label}</dt>
<dd className="mt-1 break-words text-slate-950">{value}</dd>
</div>
);
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-slate-700">{label}</span>
{children}
</label>
);
}

View file

@ -14,6 +14,15 @@ export type RepairStatus =
export type RepairPriority = "low" | "normal" | "high" | "urgent";
export type RepairSource = "manual" | "website" | "customer_portal" | "email" | "phone";
export type RepairDocumentType =
| "device_photo"
| "fault_photo"
| "measurement"
| "estimate"
| "repair_report"
| "shipping"
| "other";
export type RepairDocumentVisibility = "internal" | "customer";
export interface Repair {
id: number;
@ -132,3 +141,23 @@ export interface RepairNotificationOverview {
templates: RepairNotificationTemplate[];
events: RepairNotificationEvent[];
}
export interface RepairDocument {
id: number;
repair_id: number;
title: string;
document_type: RepairDocumentType;
original_filename: string;
stored_filename: string;
storage_path: string;
mime_type: string;
size_bytes: number;
checksum_sha256: string;
visibility: RepairDocumentVisibility;
note: string | null;
uploaded_by_user_id: number | null;
uploaded_by_username: string;
uploaded_by_display_name: string;
created_at: string;
updated_at: string;
}