feat(repairs): add status timeline and public link preparation

This commit is contained in:
Schubert Ferenc 2026-07-04 12:25:06 +02:00
parent e9ec207617
commit 09cce1f2b6
22 changed files with 1027 additions and 28 deletions

View file

@ -204,9 +204,11 @@ Kernfunktionen:
- Reparaturen manuell anlegen und bearbeiten - Reparaturen manuell anlegen und bearbeiten
- eindeutige Reparaturnummer im Format `R<jahr>-<laufende nummer sechsstellig>`, z. B. `R2026-000001` - eindeutige Reparaturnummer im Format `R<jahr>-<laufende nummer sechsstellig>`, z. B. `R2026-000001`
- Statusmodell von `new` bis `completed` oder `cancelled` - Statusmodell von `new` bis `completed` oder `cancelled`
- deutsche UI-Statuslabels in Athena, die internen API-Statuscodes bleiben englisch und stabil
- Statushistorie in `repair_status_history` - Statushistorie in `repair_status_history`
- Audit Logs fuer Erstellung, Bearbeitung, Statuswechsel, Storno und Intake - werkstattgerechte Status-Timeline mit Datum, Benutzer und Notiz
- Dashboard-Kennzahlen fuer neue Reparaturen, Diagnose, wartende Kundenfreigaben und abgeschlossene Reparaturen - Audit Logs fuer Erstellung, Bearbeitung, Statuswechsel, Storno, Intake und Statuslink-Verwaltung
- Dashboard-Kennzahlen fuer neue Reparaturen, Diagnose, wartende Kundenfreigaben, laufende Reparaturen, Endpruefung und abgeschlossene Reparaturen
Tabellen: Tabellen:
@ -214,6 +216,8 @@ Tabellen:
- `repair_status_history` - `repair_status_history`
- `repair_intake_events` - `repair_intake_events`
- `repair_documents` - `repair_documents`
- `repair_public_access_tokens`
- `repair_notification_events`
Hermes-Endpunkte: Hermes-Endpunkte:
@ -224,9 +228,66 @@ Hermes-Endpunkte:
- `DELETE /repairs/{id}` als fachliches Storno - `DELETE /repairs/{id}` als fachliches Storno
- `PUT /repairs/{id}/status` - `PUT /repairs/{id}/status`
- `GET /repairs/{id}/history` - `GET /repairs/{id}/history`
- `GET /repairs/{id}/public-link`
- `POST /repairs/{id}/public-link`
- `DELETE /repairs/{id}/public-link`
- `GET /repairs/{id}/notifications`
- `GET /public/repairs/status/{token}`
Athena-BFF-Routen spiegeln diese Endpunkte unter `/api/repairs...`. Mutierende Athena-Routen behalten die Same-Origin-Pruefung bei. Athena-BFF-Routen spiegeln diese Endpunkte unter `/api/repairs...`. Mutierende Athena-Routen behalten die Same-Origin-Pruefung bei.
Statuslabels:
```text
new = Neu
accepted = Angenommen
diagnosis = Diagnose
estimate = Kostenvoranschlag
waiting_for_customer = Wartet auf Kunde
approved = Freigegeben
repair = Reparatur
final_test = Endprüfung
ready_for_pickup = Abholbereit
shipped = Versand
completed = Abgeschlossen
cancelled = Storniert
```
### Sicherer Reparaturstatus-Link
v0.8.1 bereitet einen oeffentlichen Statuslink vor. Es wird noch kein Kundenportal und kein Kundenlogin gebaut.
Sicherheitsmodell:
- Klartexttoken wird lang und zufaellig erzeugt.
- In PostgreSQL wird nur ein HMAC/SHA-256-Hash des Tokens gespeichert.
- Der Klartexttoken wird nur einmal bei Erstellung an Athena zurueckgegeben.
- Tokens werden niemals geloggt.
- `repair_public_access_tokens` speichert Aktivstatus, Ablaufdatum, letzte Nutzung und Widerruf.
- `GET /public/repairs/status/{token}` gibt nur kundenfreundliche Statusdaten aus.
- Keine Kundendaten, internen Notizen oder Diagnosedetails werden oeffentlich ausgegeben.
Die spaetere Website kann daraus einen Pfad wie `/status/<token>` anbieten. Die eigentliche Website wird in Olympus nicht veraendert.
### Reparatur-Benachrichtigungen
`repair_notification_events` bereitet nachvollziehbare Kundenkommunikation vor. Der Versand wird noch nicht erzwungen.
Vorbereitete Ereignisse:
- Reparatur erfasst
- Geraet angenommen
- Diagnose laeuft
- Kostenvoranschlag erstellt
- Wartet auf Kundenfreigabe
- Reparatur laeuft
- Endpruefung
- Abholbereit
- Versand
- Abgeschlossen
Vorlagen enthalten deutsche Betreffzeilen und Texte mit Platzhaltern fuer `repair_number`, `customer_name`, `device`, `status_label`, `public_status_url` und `company_name`.
### Website Repair Intake ### Website Repair Intake
Hermes stellt `POST /public/repair-intake` fuer eine spaetere serverseitige Website-Anbindung bereit. Hermes stellt `POST /public/repair-intake` fuer eine spaetere serverseitige Website-Anbindung bereit.
@ -241,7 +302,7 @@ Sicherheitsmodell:
Der Intake speichert immer ein `repair_intake_events`-Ereignis und erzeugt bei erfolgreicher Verarbeitung einen Reparaturdatensatz mit `source = website` und `status = new`. Der Intake speichert immer ein `repair_intake_events`-Ereignis und erzeugt bei erfolgreicher Verarbeitung einen Reparaturdatensatz mit `source = website` und `status = new`.
Eine spaetere Kundenportal-Statusseite ist architektonisch vorbereitet, aber noch nicht oeffentlich implementiert. Eine spaetere Statusseite ist architektonisch vorbereitet, aber noch nicht in der oeffentlichen Website implementiert.
## RBAC: Rollen und Berechtigungen ## RBAC: Rollen und Berechtigungen

View file

@ -146,13 +146,31 @@ Athena erreicht Reparaturen ausschliesslich ueber BFF-Routen:
- `/api/repairs/[id]` - `/api/repairs/[id]`
- `/api/repairs/[id]/status` - `/api/repairs/[id]/status`
- `/api/repairs/[id]/history` - `/api/repairs/[id]/history`
- `/api/repairs/[id]/public-link`
- `/api/repairs/[id]/notifications`
Hermes stellt zusaetzlich `POST /public/repair-intake` fuer die spaetere Website-Anbindung bereit. Der Endpunkt ist fuer Server-zu-Server-Kommunikation gedacht und erwartet `X-Olympus-Intake-Token`. Der Token wird ueber `OLYMPUS_REPAIR_INTAKE_TOKEN` gesetzt und darf nicht im Browser verwendet werden. Hermes stellt zusaetzlich `POST /public/repair-intake` fuer die spaetere Website-Anbindung bereit. Der Endpunkt ist fuer Server-zu-Server-Kommunikation gedacht und erwartet `X-Olympus-Intake-Token`. Der Token wird ueber `OLYMPUS_REPAIR_INTAKE_TOKEN` gesetzt und darf nicht im Browser verwendet werden.
Vorbereitete Kundenportal-Routen fuer spaeter: Ab v0.8.1 zeigt Athena deutsche Statuslabels, waehrend die API-Statuscodes englisch und stabil bleiben. Statuswechsel werden als Timeline mit Benutzer, Datum und Notiz angezeigt.
- `/status` Statuslink-Konzept:
- `/reparatur/status`
- Interne Verwaltung ueber `GET|POST|DELETE /repairs/{id}/public-link`.
- RBAC-Permission: `repairs.public_link.manage`.
- Tokens sind lang, zufaellig und werden nur gehasht gespeichert.
- Der Klartexttoken wird nur einmal bei Erstellung zurueckgegeben.
- Oeffentliche Statusdaten kommen spaeter ueber `GET /public/repairs/status/{token}`.
- Die Antwort enthaelt keine Kundendaten, keine internen Notizen und keine nicht freigegebenen Diagnosen.
Benachrichtigungen:
- Vorlagen liegen in `backend/hermes/app/services/repair_notification_service.py`.
- `repair_notification_events` bereitet spaeteren E-Mail-Versand nachvollziehbar vor.
- Aktuell wird kein SMTP-Versand aus Olympus erzwungen.
Vorbereitete Website-/Portal-Routen fuer spaeter:
- `/status/<token>`
- `/portal/login` - `/portal/login`
## Typische Fehler ## Typische Fehler

View file

@ -85,12 +85,28 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi
- Dashboard-Kennzahlen fuer Reparaturen - Dashboard-Kennzahlen fuer Reparaturen
- Athena-BFF-Routen und Reparatur-UI - Athena-BFF-Routen und Reparatur-UI
## v0.8.1 - Kundenportal-Status, geplant ## v0.8.1 - Reparaturstatus und Kundenkommunikation
- Deutsche Statuslabels in Athena bei stabilen englischen API-Statuscodes
- Werkstattgerechte Status-Timeline auf der Reparaturdetailseite
- Sicheres Public-Token-Modell fuer spaetere Statuslinks
- Oeffentlicher Hermes-Endpunkt `GET /public/repairs/status/{token}` mit minimalen Kundendaten
- Interne Statuslink-Verwaltung in Athena
- E-Mail-Vorlagen und `repair_notification_events` vorbereitet
- Keine Kundenlogin-Funktion und keine Aenderungen an der oeffentlichen Website
## v0.8.2 - Website-Statusintegration, geplant
- `/status/<token>` als spaeterer Einstieg fuer Statusabfragen auf der Website
- Website ruft Statusdaten serverseitig oder ueber einen eigenen BFF ab
- Kein Token im Log und keine unnoetigen Kundendaten in der oeffentlichen Antwort
- Optionaler E-Mail-Versand mit sicherem Statuslink
## v0.8.3 - Kundenportal, geplant
- `/status` als Einstieg fuer Statusabfragen
- `/reparatur/status` fuer Reparaturstatus
- `/portal/login` fuer spaeteren Kundenlogin - `/portal/login` fuer spaeteren Kundenlogin
- Keine Browser-Secrets und keine direkten Hermes-Zugriffe - Separates Authentifizierungsmodell fuer Kunden
- Keine Vermischung mit internen Olympus-Benutzern
## v0.9.0 - Tickets, geplant ## v0.9.0 - Tickets, geplant

View file

@ -0,0 +1,126 @@
"""add repair public links and notifications
Revision ID: d4f6a2b8c901
Revises: c8e2f1a9b704
Create Date: 2026-07-04 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "d4f6a2b8c901"
down_revision: Union[str, Sequence[str], None] = "c8e2f1a9b704"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
PUBLIC_LINK_PERMISSION = (
"repairs.public_link.manage",
"Reparatur-Statuslinks verwalten",
"Öffentliche Statuslinks für Reparaturen verwalten",
"repairs",
)
ROLE_PERMISSIONS = {
"administrator": ["repairs.public_link.manage"],
"management": ["repairs.public_link.manage"],
"support": ["repairs.public_link.manage"],
}
def upgrade() -> None:
op.create_table(
"repair_public_access_tokens",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("repair_id", sa.Integer(), nullable=False),
sa.Column("token_hash", sa.String(length=64), nullable=False),
sa.Column("token_hint", sa.String(length=16), nullable=True),
sa.Column("is_active", sa.Boolean(), server_default="true", nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["repair_id"], ["repairs.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("token_hash"),
)
op.create_index(op.f("ix_repair_public_access_tokens_is_active"), "repair_public_access_tokens", ["is_active"], unique=False)
op.create_index(op.f("ix_repair_public_access_tokens_repair_id"), "repair_public_access_tokens", ["repair_id"], unique=False)
op.create_index(op.f("ix_repair_public_access_tokens_token_hash"), "repair_public_access_tokens", ["token_hash"], unique=True)
op.create_table(
"repair_notification_events",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("repair_id", sa.Integer(), nullable=False),
sa.Column("event_type", sa.String(length=80), nullable=False),
sa.Column("channel", sa.String(length=40), nullable=False),
sa.Column("recipient", sa.String(length=255), server_default="", nullable=False),
sa.Column("subject", sa.String(length=255), nullable=False),
sa.Column("status", sa.String(length=40), nullable=False),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("sent_at", sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint("status in ('pending','sent','failed','skipped')", name="ck_repair_notification_events_status"),
sa.ForeignKeyConstraint(["repair_id"], ["repairs.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_repair_notification_events_channel"), "repair_notification_events", ["channel"], unique=False)
op.create_index(op.f("ix_repair_notification_events_event_type"), "repair_notification_events", ["event_type"], unique=False)
op.create_index(op.f("ix_repair_notification_events_repair_id"), "repair_notification_events", ["repair_id"], unique=False)
op.create_index(op.f("ix_repair_notification_events_status"), "repair_notification_events", ["status"], unique=False)
name, display_name, description, module = PUBLIC_LINK_PERMISSION
op.execute(
sa.text(
"""
insert into permissions (name, display_name, description, module)
values (:name, :display_name, :description, :module)
on conflict (name) do update set
display_name = excluded.display_name,
description = excluded.description,
module = excluded.module
"""
).bindparams(name=name, display_name=display_name, description=description, module=module)
)
for role_name, permissions in ROLE_PERMISSIONS.items():
for permission_name in permissions:
op.execute(
sa.text(
"""
insert into role_permissions (role_id, permission_id)
select roles.id, permissions.id
from roles, permissions
where roles.name = :role_name and permissions.name = :permission_name
on conflict do nothing
"""
).bindparams(role_name=role_name, permission_name=permission_name)
)
def downgrade() -> None:
op.execute(
sa.text(
"""
delete from role_permissions
using permissions
where role_permissions.permission_id = permissions.id
and permissions.name = :permission_name
"""
).bindparams(permission_name=PUBLIC_LINK_PERMISSION[0])
)
op.execute(sa.text("delete from permissions where name = :name").bindparams(name=PUBLIC_LINK_PERMISSION[0]))
op.drop_index(op.f("ix_repair_notification_events_status"), table_name="repair_notification_events")
op.drop_index(op.f("ix_repair_notification_events_repair_id"), table_name="repair_notification_events")
op.drop_index(op.f("ix_repair_notification_events_event_type"), table_name="repair_notification_events")
op.drop_index(op.f("ix_repair_notification_events_channel"), table_name="repair_notification_events")
op.drop_table("repair_notification_events")
op.drop_index(op.f("ix_repair_public_access_tokens_token_hash"), table_name="repair_public_access_tokens")
op.drop_index(op.f("ix_repair_public_access_tokens_repair_id"), table_name="repair_public_access_tokens")
op.drop_index(op.f("ix_repair_public_access_tokens_is_active"), table_name="repair_public_access_tokens")
op.drop_table("repair_public_access_tokens")

View file

@ -60,7 +60,9 @@ def get_dashboard_summary(
repairs = [ repairs = [
MetricCard(label="Neue Reparaturen", value=RepairRepository.count_by_status(db, "new")), MetricCard(label="Neue Reparaturen", value=RepairRepository.count_by_status(db, "new")),
MetricCard(label="In Diagnose", value=RepairRepository.count_by_status(db, "diagnosis")), MetricCard(label="In Diagnose", value=RepairRepository.count_by_status(db, "diagnosis")),
MetricCard(label="Warten auf Kunde", value=RepairRepository.count_by_status(db, "waiting_for_customer")), MetricCard(label="Wartet auf Kunde", value=RepairRepository.count_by_status(db, "waiting_for_customer")),
MetricCard(label="In Reparatur", value=RepairRepository.count_by_status(db, "repair")),
MetricCard(label="Endprüfung", value=RepairRepository.count_by_status(db, "final_test")),
MetricCard(label="Abgeschlossen", value=RepairRepository.count_by_status(db, "completed")), MetricCard(label="Abgeschlossen", value=RepairRepository.count_by_status(db, "completed")),
] ]

View file

@ -14,12 +14,18 @@ from app.schemas.repair import (
RepairIntakePayload, RepairIntakePayload,
RepairIntakeResponse, RepairIntakeResponse,
RepairListResponse, RepairListResponse,
RepairNotificationOverviewResponse,
RepairPublicLinkCreateResponse,
RepairPublicLinkResponse,
RepairPublicStatusResponse,
RepairResponse, RepairResponse,
RepairStatus, RepairStatus,
RepairStatusHistoryResponse, RepairStatusHistoryResponse,
RepairStatusUpdate, RepairStatusUpdate,
RepairUpdate, RepairUpdate,
) )
from app.services.repair_notification_service import RepairNotificationService
from app.services.repair_public_link_service import RepairPublicLinkService
from app.services.repair_service import RepairService from app.services.repair_service import RepairService
router = APIRouter(tags=["Repairs"]) router = APIRouter(tags=["Repairs"])
@ -123,6 +129,48 @@ def get_repair_history(
return RepairRepository.get_history(db, repair_id) return RepairRepository.get_history(db, repair_id)
@router.get("/repairs/{repair_id}/public-link", response_model=RepairPublicLinkResponse)
def get_public_link(
repair_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.public_link.manage")),
):
db_repair = get_repair_or_404(db, repair_id)
return RepairPublicLinkService.get(db, db_repair)
@router.post("/repairs/{repair_id}/public-link", response_model=RepairPublicLinkCreateResponse, status_code=status.HTTP_201_CREATED)
def create_public_link(
repair_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.public_link.manage")),
):
db_repair = get_repair_or_404(db, repair_id)
return RepairPublicLinkService.create(db, db_repair, actor=current_user, request=request)
@router.delete("/repairs/{repair_id}/public-link", response_model=RepairPublicLinkResponse)
def revoke_public_link(
repair_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.public_link.manage")),
):
db_repair = get_repair_or_404(db, repair_id)
return RepairPublicLinkService.revoke(db, db_repair, actor=current_user, request=request)
@router.get("/repairs/{repair_id}/notifications", response_model=RepairNotificationOverviewResponse)
def get_repair_notifications(
repair_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.read")),
):
db_repair = get_repair_or_404(db, repair_id)
return RepairNotificationService.overview(db, db_repair)
@router.post("/public/repair-intake", response_model=RepairIntakeResponse, status_code=status.HTTP_201_CREATED) @router.post("/public/repair-intake", response_model=RepairIntakeResponse, status_code=status.HTTP_201_CREATED)
def create_repair_intake( def create_repair_intake(
payload: RepairIntakePayload, payload: RepairIntakePayload,
@ -140,3 +188,11 @@ def create_repair_intake(
status=cast(RepairStatus, repair.status), status=cast(RepairStatus, repair.status),
message="Reparaturanfrage übernommen", message="Reparaturanfrage übernommen",
) )
@router.get("/public/repairs/status/{token}", response_model=RepairPublicStatusResponse)
def get_public_repair_status(
token: str,
db: Session = Depends(get_db),
):
return RepairPublicLinkService.public_status(db, token)

View file

@ -39,6 +39,8 @@ class Repair(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
history: Mapped[list["RepairStatusHistory"]] = relationship(back_populates="repair", cascade="all, delete-orphan", lazy="selectin") 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")
class RepairStatusHistory(Base): class RepairStatusHistory(Base):
@ -53,6 +55,18 @@ class RepairStatusHistory(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
repair: Mapped[Repair] = relationship(back_populates="history") repair: Mapped[Repair] = relationship(back_populates="history")
actor = relationship("User", lazy="joined")
@property
def actor_username(self) -> str:
return self.actor.username if self.actor is not None else ""
@property
def actor_display_name(self) -> str:
if self.actor is None:
return ""
display_name = f"{self.actor.first_name} {self.actor.last_name}".strip()
return display_name or self.actor.username
class RepairIntakeEvent(Base): class RepairIntakeEvent(Base):
@ -79,3 +93,36 @@ class RepairDocument(Base):
title: Mapped[str] = mapped_column(String(255)) title: Mapped[str] = mapped_column(String(255))
document_type: Mapped[str] = mapped_column(String(80), index=True) document_type: Mapped[str] = mapped_column(String(80), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class RepairPublicAccessToken(Base):
__tablename__ = "repair_public_access_tokens"
id: Mapped[int] = mapped_column(primary_key=True)
repair_id: Mapped[int] = mapped_column(ForeignKey("repairs.id", ondelete="CASCADE"), index=True)
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
token_hint: Mapped[str | None] = mapped_column(String(16), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true", index=True)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
repair: Mapped[Repair] = relationship(back_populates="public_access_tokens")
class RepairNotificationEvent(Base):
__tablename__ = "repair_notification_events"
id: Mapped[int] = mapped_column(primary_key=True)
repair_id: Mapped[int] = mapped_column(ForeignKey("repairs.id", ondelete="CASCADE"), index=True)
event_type: Mapped[str] = mapped_column(String(80), index=True)
channel: Mapped[str] = mapped_column(String(40), index=True)
recipient: Mapped[str] = mapped_column(String(255), default="", server_default="")
subject: Mapped[str] = mapped_column(String(255))
status: Mapped[str] = mapped_column(String(40), index=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
repair: Mapped[Repair] = relationship(back_populates="notification_events")

View file

@ -83,6 +83,7 @@ STANDARD_PERMISSIONS = [
("repairs.status.update", "Reparaturstatus ändern", "Status von Reparaturen ändern", "repairs"), ("repairs.status.update", "Reparaturstatus ändern", "Status von Reparaturen ändern", "repairs"),
("repairs.intake", "Reparatur-Intake", "Website-Reparaturanfragen übernehmen", "repairs"), ("repairs.intake", "Reparatur-Intake", "Website-Reparaturanfragen übernehmen", "repairs"),
("repairs.assign", "Reparaturen zuweisen", "Reparaturen Benutzern zuweisen", "repairs"), ("repairs.assign", "Reparaturen zuweisen", "Reparaturen Benutzern zuweisen", "repairs"),
("repairs.public_link.manage", "Reparatur-Statuslinks verwalten", "Öffentliche Statuslinks für Reparaturen verwalten", "repairs"),
] ]
ROLE_PERMISSION_NAMES = { ROLE_PERMISSION_NAMES = {
@ -99,6 +100,7 @@ ROLE_PERMISSION_NAMES = {
"repairs.create", "repairs.create",
"repairs.update", "repairs.update",
"repairs.status.update", "repairs.status.update",
"repairs.public_link.manage",
}, },
"sales": { "sales": {
"dashboard.read", "dashboard.read",
@ -137,6 +139,7 @@ ROLE_PERMISSION_NAMES = {
"repairs.update", "repairs.update",
"repairs.status.update", "repairs.status.update",
"repairs.intake", "repairs.intake",
"repairs.public_link.manage",
}, },
"warehouse": { "warehouse": {
"dashboard.read", "dashboard.read",

View file

@ -3,7 +3,7 @@ from datetime import UTC, datetime
from sqlalchemy import Select, func, or_, select from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session, selectinload from sqlalchemy.orm import Session, selectinload
from app.models.repair import Repair, RepairIntakeEvent, RepairStatusHistory from app.models.repair import Repair, RepairIntakeEvent, RepairNotificationEvent, RepairPublicAccessToken, RepairStatusHistory
from app.schemas.repair import RepairCreate, RepairStatusUpdate, RepairUpdate from app.schemas.repair import RepairCreate, RepairStatusUpdate, RepairUpdate
@ -158,11 +158,22 @@ class RepairRepository:
return list( return list(
db.scalars( db.scalars(
select(RepairStatusHistory) select(RepairStatusHistory)
.options(selectinload(RepairStatusHistory.actor))
.where(RepairStatusHistory.repair_id == repair_id) .where(RepairStatusHistory.repair_id == repair_id)
.order_by(RepairStatusHistory.created_at.desc(), RepairStatusHistory.id.desc()) .order_by(RepairStatusHistory.created_at.desc(), RepairStatusHistory.id.desc())
) )
) )
@staticmethod
def get_history_public(db: Session, repair_id: int) -> list[RepairStatusHistory]:
return list(
db.scalars(
select(RepairStatusHistory)
.where(RepairStatusHistory.repair_id == repair_id)
.order_by(RepairStatusHistory.created_at.asc(), RepairStatusHistory.id.asc())
)
)
@staticmethod @staticmethod
def count_by_status(db: Session, status: str) -> int: def count_by_status(db: Session, status: str) -> int:
return db.scalar(select(func.count(Repair.id)).where(Repair.status == status)) or 0 return db.scalar(select(func.count(Repair.id)).where(Repair.status == status)) or 0
@ -203,6 +214,110 @@ class RepairRepository:
db.refresh(event) db.refresh(event)
return event return event
@staticmethod
def get_active_public_link(db: Session, repair_id: int) -> RepairPublicAccessToken | None:
now = datetime.now(UTC)
return db.scalar(
select(RepairPublicAccessToken)
.where(RepairPublicAccessToken.repair_id == repair_id)
.where(RepairPublicAccessToken.is_active.is_(True))
.where(or_(RepairPublicAccessToken.expires_at.is_(None), RepairPublicAccessToken.expires_at > now))
.order_by(RepairPublicAccessToken.created_at.desc(), RepairPublicAccessToken.id.desc())
.limit(1)
)
@staticmethod
def get_public_link_by_hash(db: Session, token_hash: str) -> RepairPublicAccessToken | None:
now = datetime.now(UTC)
return db.scalar(
select(RepairPublicAccessToken)
.options(selectinload(RepairPublicAccessToken.repair))
.where(RepairPublicAccessToken.token_hash == token_hash)
.where(RepairPublicAccessToken.is_active.is_(True))
.where(or_(RepairPublicAccessToken.expires_at.is_(None), RepairPublicAccessToken.expires_at > now))
.limit(1)
)
@staticmethod
def create_public_link(
db: Session,
*,
repair_id: int,
token_hash: str,
token_hint: str,
expires_at: datetime | None = None,
) -> RepairPublicAccessToken:
RepairRepository.revoke_public_links(db, repair_id=repair_id, commit=False)
public_link = RepairPublicAccessToken(
repair_id=repair_id,
token_hash=token_hash,
token_hint=token_hint,
expires_at=expires_at,
)
db.add(public_link)
db.commit()
db.refresh(public_link)
return public_link
@staticmethod
def revoke_public_links(db: Session, *, repair_id: int, commit: bool = True) -> None:
now = datetime.now(UTC)
links = list(
db.scalars(
select(RepairPublicAccessToken)
.where(RepairPublicAccessToken.repair_id == repair_id)
.where(RepairPublicAccessToken.is_active.is_(True))
)
)
for link in links:
link.is_active = False
link.revoked_at = now
if commit:
db.commit()
@staticmethod
def mark_public_link_used(db: Session, public_link: RepairPublicAccessToken) -> None:
public_link.last_used_at = datetime.now(UTC)
db.commit()
@staticmethod
def list_notification_events(db: Session, repair_id: int) -> list[RepairNotificationEvent]:
return list(
db.scalars(
select(RepairNotificationEvent)
.where(RepairNotificationEvent.repair_id == repair_id)
.order_by(RepairNotificationEvent.created_at.desc(), RepairNotificationEvent.id.desc())
)
)
@staticmethod
def create_notification_event(
db: Session,
*,
repair_id: int,
event_type: str,
channel: str,
recipient: str,
subject: str,
status: str,
error_message: str | None = None,
sent_at: datetime | None = None,
) -> RepairNotificationEvent:
event = RepairNotificationEvent(
repair_id=repair_id,
event_type=event_type,
channel=channel,
recipient=recipient,
subject=subject,
status=status,
error_message=error_message,
sent_at=sent_at,
)
db.add(event)
db.commit()
db.refresh(event)
return event
@staticmethod @staticmethod
def _payload_data(payload: RepairCreate | RepairUpdate) -> dict: def _payload_data(payload: RepairCreate | RepairUpdate) -> dict:
data = payload.model_dump() data = payload.model_dump()

View file

@ -129,6 +129,8 @@ class RepairStatusHistoryResponse(BaseModel):
new_status: str new_status: str
note: str | None note: str | None
actor_user_id: int | None actor_user_id: int | None
actor_username: str = ""
actor_display_name: str = ""
created_at: datetime created_at: datetime
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@ -160,3 +162,61 @@ class RepairIntakeResponse(BaseModel):
repair_number: str repair_number: str
status: RepairStatus status: RepairStatus
message: str message: str
class RepairPublicLinkResponse(BaseModel):
is_active: bool
token_hint: str | None = None
expires_at: datetime | None = None
last_used_at: datetime | None = None
created_at: datetime | None = None
revoked_at: datetime | None = None
public_status_path: str | None = None
class RepairPublicLinkCreateResponse(RepairPublicLinkResponse):
token: str
public_status_path: str
class RepairPublicStatusHistoryItem(BaseModel):
status: RepairStatus
status_label: str
created_at: datetime
class RepairPublicStatusResponse(BaseModel):
repair_number: str
public_status_label: str
device_manufacturer: str
device_model: str
status_history_public: list[RepairPublicStatusHistoryItem]
updated_at: datetime
class RepairNotificationTemplateResponse(BaseModel):
event_type: str
status: RepairStatus | None = None
subject: str
text: str
html: str | None = None
class RepairNotificationEventResponse(BaseModel):
id: int
repair_id: int
event_type: str
channel: str
recipient: str
subject: str
status: str
error_message: str | None
created_at: datetime
sent_at: datetime | None
model_config = ConfigDict(from_attributes=True)
class RepairNotificationOverviewResponse(BaseModel):
templates: list[RepairNotificationTemplateResponse]
events: list[RepairNotificationEventResponse]

View file

@ -163,6 +163,8 @@ def action_title(action: str) -> str:
"repairs.cancel": "Reparatur storniert", "repairs.cancel": "Reparatur storniert",
"repairs.intake_processed": "Reparatur-Intake verarbeitet", "repairs.intake_processed": "Reparatur-Intake verarbeitet",
"repairs.intake_failed": "Reparatur-Intake fehlgeschlagen", "repairs.intake_failed": "Reparatur-Intake fehlgeschlagen",
"repairs.public_link.create": "Reparatur-Statuslink erstellt",
"repairs.public_link.revoke": "Reparatur-Statuslink deaktiviert",
} }
return labels.get(action, action) return labels.get(action, action)

View file

@ -0,0 +1,126 @@
from dataclasses import dataclass
from sqlalchemy.orm import Session
from app.models.repair import Repair
from app.repositories.repair_repository import RepairRepository
from app.schemas.repair import RepairNotificationOverviewResponse, RepairNotificationTemplateResponse, RepairStatus
STATUS_LABELS: dict[str, str] = {
"new": "Neu",
"accepted": "Angenommen",
"diagnosis": "Diagnose",
"estimate": "Kostenvoranschlag",
"waiting_for_customer": "Wartet auf Kunde",
"approved": "Freigegeben",
"repair": "Reparatur",
"final_test": "Endprüfung",
"ready_for_pickup": "Abholbereit",
"shipped": "Versand",
"completed": "Abgeschlossen",
"cancelled": "Storniert",
}
@dataclass(frozen=True)
class RepairNotificationTemplate:
event_type: str
status: RepairStatus | None
subject: str
text: str
html: str | None = None
TEMPLATES: tuple[RepairNotificationTemplate, ...] = (
RepairNotificationTemplate(
event_type="repair_created",
status="new",
subject="Reparatur {repair_number} wurde erfasst",
text="Guten Tag {customer_name},\n\nIhre Reparatur {repair_number} für {device} wurde erfasst. Den aktuellen Status finden Sie später unter {public_status_url}.\n\n{company_name}",
),
RepairNotificationTemplate(
event_type="device_accepted",
status="accepted",
subject="Gerät zu Reparatur {repair_number} angenommen",
text="Guten Tag {customer_name},\n\nIhr Gerät {device} wurde angenommen. Aktueller Status: {status_label}.\n\n{company_name}",
),
RepairNotificationTemplate(
event_type="diagnosis_started",
status="diagnosis",
subject="Diagnose für Reparatur {repair_number} läuft",
text="Guten Tag {customer_name},\n\nwir prüfen Ihr Gerät {device}. Aktueller Status: {status_label}.\n\n{company_name}",
),
RepairNotificationTemplate(
event_type="estimate_created",
status="estimate",
subject="Kostenvoranschlag zu Reparatur {repair_number}",
text="Guten Tag {customer_name},\n\nfür Ihr Gerät {device} wurde ein Kostenvoranschlag vorbereitet. Aktueller Status: {status_label}.\n\n{company_name}",
),
RepairNotificationTemplate(
event_type="waiting_for_customer",
status="waiting_for_customer",
subject="Freigabe für Reparatur {repair_number} erforderlich",
text="Guten Tag {customer_name},\n\nfür Ihre Reparatur {repair_number} warten wir auf Ihre Rückmeldung. Aktueller Status: {status_label}.\n\n{company_name}",
),
RepairNotificationTemplate(
event_type="repair_started",
status="repair",
subject="Reparatur {repair_number} läuft",
text="Guten Tag {customer_name},\n\nwir bearbeiten Ihr Gerät {device}. Aktueller Status: {status_label}.\n\n{company_name}",
),
RepairNotificationTemplate(
event_type="final_test",
status="final_test",
subject="Endprüfung für Reparatur {repair_number}",
text="Guten Tag {customer_name},\n\nIhr Gerät {device} befindet sich in der Endprüfung. Aktueller Status: {status_label}.\n\n{company_name}",
),
RepairNotificationTemplate(
event_type="ready_for_pickup",
status="ready_for_pickup",
subject="Reparatur {repair_number} ist abholbereit",
text="Guten Tag {customer_name},\n\nIhr Gerät {device} ist abholbereit. Aktueller Status: {status_label}.\n\n{company_name}",
),
RepairNotificationTemplate(
event_type="shipped",
status="shipped",
subject="Reparatur {repair_number} wurde versendet",
text="Guten Tag {customer_name},\n\nIhr Gerät {device} wurde versendet. Aktueller Status: {status_label}.\n\n{company_name}",
),
RepairNotificationTemplate(
event_type="completed",
status="completed",
subject="Reparatur {repair_number} abgeschlossen",
text="Guten Tag {customer_name},\n\nIhre Reparatur {repair_number} wurde abgeschlossen. Vielen Dank.\n\n{company_name}",
),
)
class RepairNotificationService:
@staticmethod
def render_templates(repair: Repair, *, public_status_url: str = "") -> list[RepairNotificationTemplateResponse]:
placeholders = {
"repair_number": repair.repair_number,
"customer_name": repair.customer_name,
"device": f"{repair.device_manufacturer} {repair.device_model}".strip(),
"status_label": STATUS_LABELS.get(repair.status, repair.status),
"public_status_url": public_status_url or "wird später bereitgestellt",
"company_name": "Olympus CRM",
}
return [
RepairNotificationTemplateResponse(
event_type=template.event_type,
status=template.status,
subject=template.subject.format(**placeholders),
text=template.text.format(**placeholders),
html=template.html.format(**placeholders) if template.html else None,
)
for template in TEMPLATES
]
@staticmethod
def overview(db: Session, repair: Repair, *, public_status_url: str = "") -> RepairNotificationOverviewResponse:
return RepairNotificationOverviewResponse(
templates=RepairNotificationService.render_templates(repair, public_status_url=public_status_url),
events=RepairRepository.list_notification_events(db, repair.id),
)

View file

@ -0,0 +1,124 @@
import hashlib
import hmac
import secrets
from datetime import UTC, datetime
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from starlette.requests import Request
from app.core.config import settings
from app.models.repair import Repair, RepairPublicAccessToken
from app.models.user import User
from app.repositories.repair_repository import RepairRepository
from app.schemas.repair import (
RepairPublicLinkCreateResponse,
RepairPublicLinkResponse,
RepairPublicStatusHistoryItem,
RepairPublicStatusResponse,
)
from app.services.audit_service import write_audit_log
from app.services.repair_notification_service import STATUS_LABELS
from app.services.repair_service import repair_label
def public_status_path(token: str) -> str:
return f"/status/{token}"
class RepairPublicLinkService:
@staticmethod
def hash_token(token: str) -> str:
return hmac.new(settings.secret_key.encode("utf-8"), token.encode("utf-8"), hashlib.sha256).hexdigest()
@staticmethod
def create_token() -> str:
return secrets.token_urlsafe(48)
@staticmethod
def response_from_link(public_link: RepairPublicAccessToken | None) -> RepairPublicLinkResponse:
if public_link is None:
return RepairPublicLinkResponse(is_active=False)
return RepairPublicLinkResponse(
is_active=public_link.is_active,
token_hint=public_link.token_hint,
expires_at=public_link.expires_at,
last_used_at=public_link.last_used_at,
created_at=public_link.created_at,
revoked_at=public_link.revoked_at,
)
@staticmethod
def get(db: Session, repair: Repair) -> RepairPublicLinkResponse:
return RepairPublicLinkService.response_from_link(RepairRepository.get_active_public_link(db, repair.id))
@staticmethod
def create(db: Session, repair: Repair, *, actor: User, request: Request) -> RepairPublicLinkCreateResponse:
token = RepairPublicLinkService.create_token()
public_link = RepairRepository.create_public_link(
db,
repair_id=repair.id,
token_hash=RepairPublicLinkService.hash_token(token),
token_hint=token[-6:],
)
write_audit_log(
db,
action="repairs.public_link.create",
entity_type="repairs",
entity_id=repair.id,
entity_label=repair_label(repair),
actor=actor,
request=request,
metadata={"token_hint": public_link.token_hint},
)
return RepairPublicLinkCreateResponse(
is_active=True,
token=token,
token_hint=public_link.token_hint,
expires_at=public_link.expires_at,
last_used_at=public_link.last_used_at,
created_at=public_link.created_at,
revoked_at=public_link.revoked_at,
public_status_path=public_status_path(token),
)
@staticmethod
def revoke(db: Session, repair: Repair, *, actor: User, request: Request) -> RepairPublicLinkResponse:
active_link = RepairRepository.get_active_public_link(db, repair.id)
RepairRepository.revoke_public_links(db, repair_id=repair.id)
write_audit_log(
db,
action="repairs.public_link.revoke",
entity_type="repairs",
entity_id=repair.id,
entity_label=repair_label(repair),
actor=actor,
request=request,
metadata={"token_hint": active_link.token_hint if active_link else None},
)
return RepairPublicLinkResponse(is_active=False, token_hint=active_link.token_hint if active_link else None, revoked_at=datetime.now(UTC))
@staticmethod
def public_status(db: Session, token: str) -> RepairPublicStatusResponse:
public_link = RepairRepository.get_public_link_by_hash(db, RepairPublicLinkService.hash_token(token))
if public_link is None or public_link.repair is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparaturstatus nicht gefunden")
RepairRepository.mark_public_link_used(db, public_link)
repair = public_link.repair
history = RepairRepository.get_history_public(db, repair.id)
return RepairPublicStatusResponse(
repair_number=repair.repair_number,
public_status_label=STATUS_LABELS.get(repair.status, repair.status),
device_manufacturer=repair.device_manufacturer,
device_model=repair.device_model,
status_history_public=[
RepairPublicStatusHistoryItem(
status=item.new_status,
status_label=STATUS_LABELS.get(item.new_status, item.new_status),
created_at=item.created_at,
)
for item in history
],
updated_at=repair.updated_at,
)

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

View file

@ -0,0 +1,39 @@
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;
}>;
};
async function proxyPublicLinkRequest(request: NextRequest, { params }: Params) {
const { id } = await params;
return proxyHermesRequest(request, `/repairs/${id}/public-link`);
}
export async function GET(request: NextRequest, context: Params) {
return proxyPublicLinkRequest(request, context);
}
export async function POST(request: NextRequest, context: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyPublicLinkRequest(request, context);
}
export async function DELETE(request: NextRequest, context: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyPublicLinkRequest(request, context);
}

View file

@ -2,18 +2,26 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { ArrowLeft, Edit, Wrench } from "lucide-react"; import { ArrowLeft, CheckCircle2, Copy, Edit, Link2, Mail, ShieldCheck, Wrench, XCircle } from "lucide-react";
import DetailSection from "@/components/common/DetailSection"; import DetailSection from "@/components/common/DetailSection";
import { useToast } from "@/components/common/ToastProvider"; import { useToast } from "@/components/common/ToastProvider";
import RepairFormDialog from "@/components/repairs/RepairFormDialog"; import RepairFormDialog from "@/components/repairs/RepairFormDialog";
import { RepairPriorityBadge, RepairStatusBadge } from "@/components/repairs/RepairStatusBadge"; import { RepairPriorityBadge, RepairStatusBadge, statusLabels } from "@/components/repairs/RepairStatusBadge";
import RepairStatusDialog from "@/components/repairs/RepairStatusDialog"; import RepairStatusDialog from "@/components/repairs/RepairStatusDialog";
import { Button, buttonVariants } from "@/components/ui/button"; import { Button, buttonVariants } from "@/components/ui/button";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { hasPermission } from "@/lib/permissions"; import { hasPermission } from "@/lib/permissions";
import type { CurrentUser } from "@/types/rbac"; import type { CurrentUser } from "@/types/rbac";
import type { Repair, RepairPayload, RepairStatus, RepairStatusHistory } from "@/types/repair"; import type {
Repair,
RepairNotificationOverview,
RepairPayload,
RepairPublicLink,
RepairPublicLinkCreated,
RepairStatus,
RepairStatusHistory,
} from "@/types/repair";
type Params = { type Params = {
params: Promise<{ params: Promise<{
@ -43,11 +51,19 @@ function DetailItem({ label, value }: { label: string; value?: string | number |
); );
} }
function historyLabel(item: RepairStatusHistory) {
const oldStatus = item.old_status ? statusLabels[item.old_status as RepairStatus] ?? item.old_status : "Start";
return `${oldStatus}${statusLabels[item.new_status]}`;
}
export default function RepairDetailPage({ params }: Params) { export default function RepairDetailPage({ params }: Params) {
const { showToast } = useToast(); const { showToast } = useToast();
const [repairId, setRepairId] = useState(""); const [repairId, setRepairId] = useState("");
const [repair, setRepair] = useState<Repair | null>(null); const [repair, setRepair] = useState<Repair | null>(null);
const [history, setHistory] = useState<RepairStatusHistory[]>([]); const [history, setHistory] = useState<RepairStatusHistory[]>([]);
const [publicLink, setPublicLink] = useState<RepairPublicLink | null>(null);
const [lastCreatedStatusPath, setLastCreatedStatusPath] = useState("");
const [notifications, setNotifications] = useState<RepairNotificationOverview | null>(null);
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null); const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
@ -56,6 +72,7 @@ export default function RepairDetailPage({ params }: Params) {
const [statusOpen, setStatusOpen] = useState(false); const [statusOpen, setStatusOpen] = useState(false);
const [formError, setFormError] = useState(""); const [formError, setFormError] = useState("");
const [statusError, setStatusError] = useState(""); const [statusError, setStatusError] = useState("");
const [communicationPending, setCommunicationPending] = useState(false);
useEffect(() => { useEffect(() => {
params.then((resolved) => setRepairId(resolved.id)); params.then((resolved) => setRepairId(resolved.id));
@ -74,6 +91,15 @@ export default function RepairDetailPage({ params }: Params) {
setRepair(repairResponse.data); setRepair(repairResponse.data);
setHistory(historyResponse.data); setHistory(historyResponse.data);
setCurrentUser(meResponse.data); setCurrentUser(meResponse.data);
const notificationResponse = await api.get<RepairNotificationOverview>(`/repairs/${repairId}/notifications`);
setNotifications(notificationResponse.data);
if (hasPermission(meResponse.data, "repairs.public_link.manage")) {
const publicLinkResponse = await api.get<RepairPublicLink>(`/repairs/${repairId}/public-link`);
setPublicLink(publicLinkResponse.data);
} else {
setPublicLink(null);
}
setLastCreatedStatusPath("");
} catch (err) { } catch (err) {
setError(getErrorMessage(err)); setError(getErrorMessage(err));
} finally { } finally {
@ -112,8 +138,10 @@ export default function RepairDetailPage({ params }: Params) {
try { try {
const response = await api.put<Repair>(`/repairs/${repair.id}/status`, { status, note }); const response = await api.put<Repair>(`/repairs/${repair.id}/status`, { status, note });
const historyResponse = await api.get<RepairStatusHistory[]>(`/repairs/${repair.id}/history`); const historyResponse = await api.get<RepairStatusHistory[]>(`/repairs/${repair.id}/history`);
const notificationResponse = await api.get<RepairNotificationOverview>(`/repairs/${repair.id}/notifications`);
setRepair(response.data); setRepair(response.data);
setHistory(historyResponse.data); setHistory(historyResponse.data);
setNotifications(notificationResponse.data);
setStatusOpen(false); setStatusOpen(false);
showToast({ type: "success", title: "Status aktualisiert", description: response.data.repair_number }); showToast({ type: "success", title: "Status aktualisiert", description: response.data.repair_number });
} catch (err) { } catch (err) {
@ -135,6 +163,53 @@ export default function RepairDetailPage({ params }: Params) {
const canUpdate = hasPermission(currentUser, "repairs.update"); const canUpdate = hasPermission(currentUser, "repairs.update");
const canUpdateStatus = hasPermission(currentUser, "repairs.status.update"); const canUpdateStatus = hasPermission(currentUser, "repairs.status.update");
const canManagePublicLink = hasPermission(currentUser, "repairs.public_link.manage");
async function createPublicLink() {
if (!repair || !canManagePublicLink) return;
setCommunicationPending(true);
try {
const response = await api.post<RepairPublicLinkCreated>(`/repairs/${repair.id}/public-link`);
setPublicLink(response.data);
setLastCreatedStatusPath(response.data.public_status_path);
showToast({ type: "success", title: "Statuslink erstellt", description: "Der Klartextlink wird nur jetzt angezeigt." });
} catch (err) {
const message = getErrorMessage(err);
showToast({ type: "error", title: "Statuslink konnte nicht erstellt werden", description: message });
} finally {
setCommunicationPending(false);
}
}
async function revokePublicLink() {
if (!repair || !canManagePublicLink) return;
setCommunicationPending(true);
try {
const response = await api.delete<RepairPublicLink>(`/repairs/${repair.id}/public-link`);
setPublicLink(response.data);
setLastCreatedStatusPath("");
showToast({ type: "success", title: "Statuslink deaktiviert", description: repair.repair_number });
} catch (err) {
const message = getErrorMessage(err);
showToast({ type: "error", title: "Statuslink konnte nicht deaktiviert werden", description: message });
} finally {
setCommunicationPending(false);
}
}
async function copyPublicLink() {
if (!lastCreatedStatusPath) {
showToast({ type: "error", title: "Kein Klartextlink verfügbar", description: "Der sichere Link wird nur direkt nach der Erstellung angezeigt." });
return;
}
const absolutePath = new URL(lastCreatedStatusPath, window.location.origin).toString();
try {
await navigator.clipboard.writeText(absolutePath);
showToast({ type: "success", title: "Link kopiert", description: lastCreatedStatusPath });
} catch {
showToast({ type: "error", title: "Link konnte nicht kopiert werden", description: "Bitte kopiere den angezeigten Link manuell." });
}
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@ -208,20 +283,90 @@ export default function RepairDetailPage({ params }: Params) {
{history.length === 0 ? ( {history.length === 0 ? (
<p className="text-sm text-slate-500">Noch keine Statushistorie vorhanden.</p> <p className="text-sm text-slate-500">Noch keine Statushistorie vorhanden.</p>
) : ( ) : (
<div className="divide-y"> <div className="space-y-0">
{history.map((item) => ( {history.map((item) => (
<div key={item.id} className="py-3"> <div key={item.id} className="relative border-l border-slate-200 pb-5 pl-6 last:pb-0">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between"> <span className={`absolute -left-2 top-1 h-4 w-4 rounded-full ring-4 ring-white ${item.new_status === repair.status ? "bg-blue-600" : "bg-slate-300"}`} />
<p className="font-medium text-slate-950">{item.old_status || "Start"} {item.new_status}</p> <div className="flex flex-col gap-2 rounded-lg border bg-slate-50 p-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<p className="font-medium text-slate-950">{historyLabel(item)}</p>
{item.new_status === repair.status && <span className="rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-blue-600/20">Aktuell</span>}
</div>
<p className="text-xs text-slate-500">
{item.actor_display_name || item.actor_username || "System"}
</p>
{item.note && <p className="whitespace-pre-wrap text-sm text-slate-600">{item.note}</p>}
</div>
<time className="text-xs text-slate-500" dateTime={item.created_at}>{formatDate(item.created_at)}</time> <time className="text-xs text-slate-500" dateTime={item.created_at}>{formatDate(item.created_at)}</time>
</div> </div>
{item.note && <p className="mt-1 text-sm text-slate-500">{item.note}</p>}
</div> </div>
))} ))}
</div> </div>
)} )}
</DetailSection> </DetailSection>
<DetailSection title="Kundenkommunikation">
<div className="grid gap-4 xl:grid-cols-[1fr_1.3fr]">
<div className="space-y-4 rounded-lg border bg-slate-50 p-4">
<div className="flex items-start gap-3">
{publicLink?.is_active ? <CheckCircle2 className="mt-0.5 text-emerald-600" size={20} /> : <XCircle className="mt-0.5 text-slate-400" size={20} />}
<div>
<p className="font-medium text-slate-950">Öffentlicher Statuslink {publicLink?.is_active ? "aktiv" : "inaktiv"}</p>
<p className="mt-1 text-sm text-slate-500">
Kunden erhalten später Statusupdates per E-Mail mit sicherem Link. Ein Kundenlogin ist dafür nicht erforderlich.
</p>
</div>
</div>
<dl className="grid gap-3 text-sm">
<DetailItem label="Token-Hinweis" value={publicLink?.token_hint ? `...${publicLink.token_hint}` : "-"} />
<DetailItem label="Zuletzt verwendet" value={formatDate(publicLink?.last_used_at)} />
<DetailItem label="Erstellt" value={formatDate(publicLink?.created_at)} />
</dl>
{lastCreatedStatusPath && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900">
<p className="font-medium">Einmaliger Klartextlink</p>
<p className="mt-1 break-all">{lastCreatedStatusPath}</p>
</div>
)}
{canManagePublicLink ? (
<div className="flex flex-wrap gap-2">
<Button type="button" onClick={createPublicLink} disabled={communicationPending}><Link2 size={16} />Statuslink erstellen</Button>
<Button type="button" variant="outline" onClick={copyPublicLink} disabled={!lastCreatedStatusPath || communicationPending}><Copy size={16} />Link kopieren</Button>
<Button type="button" variant="destructive" onClick={revokePublicLink} disabled={!publicLink?.is_active || communicationPending}>Statuslink deaktivieren</Button>
</div>
) : (
<p className="text-sm text-slate-500">Für die Verwaltung des Statuslinks ist `repairs.public_link.manage` erforderlich.</p>
)}
</div>
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-medium text-slate-950">
<Mail size={16} />
Vorbereitete Benachrichtigungen
</div>
<div className="grid gap-2">
{notifications?.templates.map((template) => (
<div key={template.event_type} className="rounded-lg border bg-white p-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="font-medium text-slate-950">{template.subject}</p>
{template.status && <RepairStatusBadge status={template.status} />}
</div>
<p className="mt-2 line-clamp-2 text-sm text-slate-500">{template.text}</p>
</div>
))}
{!notifications?.templates.length && <p className="text-sm text-slate-500">Noch keine Vorlagen vorbereitet.</p>}
</div>
<div className="rounded-lg border bg-slate-50 p-3 text-sm text-slate-500">
<div className="flex items-start gap-2">
<ShieldCheck className="mt-0.5 text-slate-500" size={16} />
<p>Notification Events werden gespeichert, sobald später echter E-Mail-Versand angebunden wird. Interne Notizen werden nicht öffentlich ausgegeben.</p>
</div>
</div>
</div>
</div>
</DetailSection>
<DetailSection title="Audit und Aktivität vorbereitet"> <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> <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> <Link href="/audit-logs" className={buttonVariants({ variant: "outline", size: "sm", className: "mt-4" })}>Audit Logs öffnen</Link>

View file

@ -10,7 +10,7 @@ import EmptyState from "@/components/common/EmptyState";
import SearchInput from "@/components/common/SearchInput"; import SearchInput from "@/components/common/SearchInput";
import { useToast } from "@/components/common/ToastProvider"; import { useToast } from "@/components/common/ToastProvider";
import RepairFormDialog from "@/components/repairs/RepairFormDialog"; import RepairFormDialog from "@/components/repairs/RepairFormDialog";
import { RepairPriorityBadge, RepairStatusBadge } from "@/components/repairs/RepairStatusBadge"; import { RepairPriorityBadge, RepairStatusBadge, priorityLabels, statusLabels } from "@/components/repairs/RepairStatusBadge";
import RepairStatusDialog from "@/components/repairs/RepairStatusDialog"; import RepairStatusDialog from "@/components/repairs/RepairStatusDialog";
import { Button, buttonVariants } from "@/components/ui/button"; import { Button, buttonVariants } from "@/components/ui/button";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
@ -212,7 +212,7 @@ export default function RepairsPage() {
<SearchInput value={search} onChange={(value) => { setSearch(value); setPage(1); }} placeholder="Reparaturen suchen" /> <SearchInput value={search} onChange={(value) => { setSearch(value); setPage(1); }} placeholder="Reparaturen suchen" />
<select value={statusFilter} onChange={(event) => { setStatusFilter(event.target.value as RepairStatus | "all"); setPage(1); }} className="h-8 rounded-lg border border-input bg-transparent px-2.5 text-sm"> <select value={statusFilter} onChange={(event) => { setStatusFilter(event.target.value as RepairStatus | "all"); setPage(1); }} className="h-8 rounded-lg border border-input bg-transparent px-2.5 text-sm">
<option value="all">Alle Status</option> <option value="all">Alle Status</option>
{["new", "accepted", "diagnosis", "estimate", "waiting_for_customer", "approved", "repair", "final_test", "ready_for_pickup", "shipped", "completed", "cancelled"].map((status) => <option key={status} value={status}>{status}</option>)} {(["new", "accepted", "diagnosis", "estimate", "waiting_for_customer", "approved", "repair", "final_test", "ready_for_pickup", "shipped", "completed", "cancelled"] as RepairStatus[]).map((status) => <option key={status} value={status}>{statusLabels[status]}</option>)}
</select> </select>
<select value={sourceFilter} onChange={(event) => { setSourceFilter(event.target.value as RepairSource | "all"); setPage(1); }} className="h-8 rounded-lg border border-input bg-transparent px-2.5 text-sm"> <select value={sourceFilter} onChange={(event) => { setSourceFilter(event.target.value as RepairSource | "all"); setPage(1); }} className="h-8 rounded-lg border border-input bg-transparent px-2.5 text-sm">
<option value="all">Alle Quellen</option> <option value="all">Alle Quellen</option>
@ -220,7 +220,7 @@ export default function RepairsPage() {
</select> </select>
<select value={priorityFilter} onChange={(event) => { setPriorityFilter(event.target.value as RepairPriority | "all"); setPage(1); }} className="h-8 rounded-lg border border-input bg-transparent px-2.5 text-sm"> <select value={priorityFilter} onChange={(event) => { setPriorityFilter(event.target.value as RepairPriority | "all"); setPage(1); }} className="h-8 rounded-lg border border-input bg-transparent px-2.5 text-sm">
<option value="all">Alle Prioritäten</option> <option value="all">Alle Prioritäten</option>
{["low", "normal", "high", "urgent"].map((priority) => <option key={priority} value={priority}>{priority}</option>)} {(["low", "normal", "high", "urgent"] as RepairPriority[]).map((priority) => <option key={priority} value={priority}>{priorityLabels[priority]}</option>)}
</select> </select>
</div> </div>

View file

@ -12,6 +12,7 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { priorityLabels, statusLabels } from "@/components/repairs/RepairStatusBadge";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import type { Customer } from "@/types/customer"; import type { Customer } from "@/types/customer";
import type { Repair, RepairPayload, RepairPriority, RepairSource, RepairStatus } from "@/types/repair"; import type { Repair, RepairPayload, RepairPriority, RepairSource, RepairStatus } from "@/types/repair";
@ -180,10 +181,10 @@ export default function RepairFormDialog({ open, repair, pending, serverError, o
<h3 className="font-semibold text-slate-950">Vorgang</h3> <h3 className="font-semibold text-slate-950">Vorgang</h3>
<div className="grid gap-3 md:grid-cols-3"> <div className="grid gap-3 md:grid-cols-3">
<label className="grid gap-1 text-sm">Status<select className="h-8 rounded-lg border px-2 text-sm" value={payload.status} onChange={(event) => update("status", event.target.value as RepairStatus)}> <label className="grid gap-1 text-sm">Status<select className="h-8 rounded-lg border px-2 text-sm" value={payload.status} onChange={(event) => update("status", event.target.value as RepairStatus)}>
{["new", "accepted", "diagnosis", "estimate", "waiting_for_customer", "approved", "repair", "final_test", "ready_for_pickup", "shipped", "completed", "cancelled"].map((status) => <option key={status} value={status}>{status}</option>)} {(["new", "accepted", "diagnosis", "estimate", "waiting_for_customer", "approved", "repair", "final_test", "ready_for_pickup", "shipped", "completed", "cancelled"] as RepairStatus[]).map((status) => <option key={status} value={status}>{statusLabels[status]}</option>)}
</select></label> </select></label>
<label className="grid gap-1 text-sm">Priorität<select className="h-8 rounded-lg border px-2 text-sm" value={payload.priority} onChange={(event) => update("priority", event.target.value as RepairPriority)}> <label className="grid gap-1 text-sm">Priorität<select className="h-8 rounded-lg border px-2 text-sm" value={payload.priority} onChange={(event) => update("priority", event.target.value as RepairPriority)}>
{["low", "normal", "high", "urgent"].map((priority) => <option key={priority} value={priority}>{priority}</option>)} {(["low", "normal", "high", "urgent"] as RepairPriority[]).map((priority) => <option key={priority} value={priority}>{priorityLabels[priority]}</option>)}
</select></label> </select></label>
<label className="grid gap-1 text-sm">Quelle<select className="h-8 rounded-lg border px-2 text-sm" value={payload.source} onChange={(event) => update("source", event.target.value as RepairSource)}> <label className="grid gap-1 text-sm">Quelle<select className="h-8 rounded-lg border px-2 text-sm" value={payload.source} onChange={(event) => update("source", event.target.value as RepairSource)}>
{["manual", "website", "customer_portal", "email", "phone"].map((source) => <option key={source} value={source}>{source}</option>)} {["manual", "website", "customer_portal", "email", "phone"].map((source) => <option key={source} value={source}>{source}</option>)}

View file

@ -10,7 +10,7 @@ const statusLabels: Record<RepairStatus, string> = {
repair: "Reparatur", repair: "Reparatur",
final_test: "Endprüfung", final_test: "Endprüfung",
ready_for_pickup: "Abholbereit", ready_for_pickup: "Abholbereit",
shipped: "Versendet", shipped: "Versand",
completed: "Abgeschlossen", completed: "Abgeschlossen",
cancelled: "Storniert", cancelled: "Storniert",
}; };

View file

@ -4,6 +4,7 @@ import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { statusLabels } from "@/components/repairs/RepairStatusBadge";
import type { Repair, RepairStatus } from "@/types/repair"; import type { Repair, RepairStatus } from "@/types/repair";
type Props = { type Props = {
@ -18,7 +19,7 @@ type Props = {
const statuses: RepairStatus[] = ["new", "accepted", "diagnosis", "estimate", "waiting_for_customer", "approved", "repair", "final_test", "ready_for_pickup", "shipped", "completed", "cancelled"]; const statuses: RepairStatus[] = ["new", "accepted", "diagnosis", "estimate", "waiting_for_customer", "approved", "repair", "final_test", "ready_for_pickup", "shipped", "completed", "cancelled"];
export default function RepairStatusDialog({ open, repair, pending, serverError, onOpenChange, onSubmit }: Props) { export default function RepairStatusDialog({ open, repair, pending, serverError, onOpenChange, onSubmit }: Props) {
const [status, setStatus] = useState<RepairStatus>("new"); const [status, setStatus] = useState<RepairStatus>(repair?.status ?? "new");
const [note, setNote] = useState(""); const [note, setNote] = useState("");
return ( return (
@ -30,7 +31,7 @@ export default function RepairStatusDialog({ open, repair, pending, serverError,
</DialogHeader> </DialogHeader>
<div className="grid gap-3"> <div className="grid gap-3">
<label className="grid gap-1 text-sm">Neuer Status<select className="h-8 rounded-lg border px-2 text-sm" value={status} onChange={(event) => setStatus(event.target.value as RepairStatus)}> <label className="grid gap-1 text-sm">Neuer Status<select className="h-8 rounded-lg border px-2 text-sm" value={status} onChange={(event) => setStatus(event.target.value as RepairStatus)}>
{statuses.map((item) => <option key={item} value={item}>{item}</option>)} {statuses.map((item) => <option key={item} value={item}>{statusLabels[item]}</option>)}
</select></label> </select></label>
<label className="grid gap-1 text-sm">Notiz<textarea className="min-h-24 rounded-lg border p-2 text-sm" value={note} onChange={(event) => setNote(event.target.value)} /></label> <label className="grid gap-1 text-sm">Notiz<textarea className="min-h-24 rounded-lg border p-2 text-sm" value={note} onChange={(event) => setNote(event.target.value)} /></label>
{serverError && <p className="text-sm text-red-600">{serverError}</p>} {serverError && <p className="text-sm text-red-600">{serverError}</p>}

View file

@ -85,5 +85,48 @@ export interface RepairStatusHistory {
new_status: RepairStatus; new_status: RepairStatus;
note: string | null; note: string | null;
actor_user_id: number | null; actor_user_id: number | null;
actor_username: string;
actor_display_name: string;
created_at: string; created_at: string;
} }
export interface RepairPublicLink {
is_active: boolean;
token_hint: string | null;
expires_at: string | null;
last_used_at: string | null;
created_at: string | null;
revoked_at: string | null;
public_status_path: string | null;
}
export interface RepairPublicLinkCreated extends RepairPublicLink {
token: string;
public_status_path: string;
}
export interface RepairNotificationTemplate {
event_type: string;
status: RepairStatus | null;
subject: string;
text: string;
html: string | null;
}
export interface RepairNotificationEvent {
id: number;
repair_id: number;
event_type: string;
channel: string;
recipient: string;
subject: string;
status: "pending" | "sent" | "failed" | "skipped";
error_message: string | null;
created_at: string;
sent_at: string | null;
}
export interface RepairNotificationOverview {
templates: RepairNotificationTemplate[];
events: RepairNotificationEvent[];
}