diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3725053..65f6edf 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -204,9 +204,11 @@ Kernfunktionen: - Reparaturen manuell anlegen und bearbeiten - eindeutige Reparaturnummer im Format `R-`, z. B. `R2026-000001` - 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` -- Audit Logs fuer Erstellung, Bearbeitung, Statuswechsel, Storno und Intake -- Dashboard-Kennzahlen fuer neue Reparaturen, Diagnose, wartende Kundenfreigaben und abgeschlossene Reparaturen +- werkstattgerechte Status-Timeline mit Datum, Benutzer und Notiz +- 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: @@ -214,6 +216,8 @@ Tabellen: - `repair_status_history` - `repair_intake_events` - `repair_documents` +- `repair_public_access_tokens` +- `repair_notification_events` Hermes-Endpunkte: @@ -224,9 +228,66 @@ Hermes-Endpunkte: - `DELETE /repairs/{id}` als fachliches Storno - `PUT /repairs/{id}/status` - `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. +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/` 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 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`. -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 diff --git a/README-DEV.md b/README-DEV.md index d977ce5..0ad90a3 100644 --- a/README-DEV.md +++ b/README-DEV.md @@ -146,13 +146,31 @@ Athena erreicht Reparaturen ausschliesslich ueber BFF-Routen: - `/api/repairs/[id]` - `/api/repairs/[id]/status` - `/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. -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` -- `/reparatur/status` +Statuslink-Konzept: + +- 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/` - `/portal/login` ## Typische Fehler diff --git a/ROADMAP.md b/ROADMAP.md index 3cfaf17..98f0b08 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -85,12 +85,28 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi - Dashboard-Kennzahlen fuer Reparaturen - 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/` 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 -- Keine Browser-Secrets und keine direkten Hermes-Zugriffe +- Separates Authentifizierungsmodell fuer Kunden +- Keine Vermischung mit internen Olympus-Benutzern ## v0.9.0 - Tickets, geplant diff --git a/backend/hermes/alembic/versions/d4f6a2b8c901_add_repair_public_links_notifications.py b/backend/hermes/alembic/versions/d4f6a2b8c901_add_repair_public_links_notifications.py new file mode 100644 index 0000000..a4ea3ab --- /dev/null +++ b/backend/hermes/alembic/versions/d4f6a2b8c901_add_repair_public_links_notifications.py @@ -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") diff --git a/backend/hermes/app/api/dashboard.py b/backend/hermes/app/api/dashboard.py index 05cb601..a70dc6a 100644 --- a/backend/hermes/app/api/dashboard.py +++ b/backend/hermes/app/api/dashboard.py @@ -60,7 +60,9 @@ def get_dashboard_summary( repairs = [ MetricCard(label="Neue Reparaturen", value=RepairRepository.count_by_status(db, "new")), 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")), ] diff --git a/backend/hermes/app/api/repairs.py b/backend/hermes/app/api/repairs.py index 0973960..ffdc53b 100644 --- a/backend/hermes/app/api/repairs.py +++ b/backend/hermes/app/api/repairs.py @@ -14,12 +14,18 @@ from app.schemas.repair import ( RepairIntakePayload, RepairIntakeResponse, RepairListResponse, + RepairNotificationOverviewResponse, + RepairPublicLinkCreateResponse, + RepairPublicLinkResponse, + RepairPublicStatusResponse, RepairResponse, RepairStatus, RepairStatusHistoryResponse, RepairStatusUpdate, 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 router = APIRouter(tags=["Repairs"]) @@ -123,6 +129,48 @@ def get_repair_history( 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) def create_repair_intake( payload: RepairIntakePayload, @@ -140,3 +188,11 @@ def create_repair_intake( status=cast(RepairStatus, repair.status), 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) diff --git a/backend/hermes/app/models/repair.py b/backend/hermes/app/models/repair.py index 806992e..5eae60b 100644 --- a/backend/hermes/app/models/repair.py +++ b/backend/hermes/app/models/repair.py @@ -39,6 +39,8 @@ class Repair(Base): 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") + 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): @@ -53,6 +55,18 @@ class RepairStatusHistory(Base): created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) 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): @@ -79,3 +93,36 @@ class RepairDocument(Base): title: Mapped[str] = mapped_column(String(255)) document_type: Mapped[str] = mapped_column(String(80), index=True) 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") diff --git a/backend/hermes/app/rbac/defaults.py b/backend/hermes/app/rbac/defaults.py index 3289856..2cde2c8 100644 --- a/backend/hermes/app/rbac/defaults.py +++ b/backend/hermes/app/rbac/defaults.py @@ -83,6 +83,7 @@ STANDARD_PERMISSIONS = [ ("repairs.status.update", "Reparaturstatus ändern", "Status von Reparaturen ändern", "repairs"), ("repairs.intake", "Reparatur-Intake", "Website-Reparaturanfragen übernehmen", "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 = { @@ -99,6 +100,7 @@ ROLE_PERMISSION_NAMES = { "repairs.create", "repairs.update", "repairs.status.update", + "repairs.public_link.manage", }, "sales": { "dashboard.read", @@ -137,6 +139,7 @@ ROLE_PERMISSION_NAMES = { "repairs.update", "repairs.status.update", "repairs.intake", + "repairs.public_link.manage", }, "warehouse": { "dashboard.read", diff --git a/backend/hermes/app/repositories/repair_repository.py b/backend/hermes/app/repositories/repair_repository.py index 20e860c..6ec0e2d 100644 --- a/backend/hermes/app/repositories/repair_repository.py +++ b/backend/hermes/app/repositories/repair_repository.py @@ -3,7 +3,7 @@ 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, RepairStatusHistory +from app.models.repair import Repair, RepairIntakeEvent, RepairNotificationEvent, RepairPublicAccessToken, RepairStatusHistory from app.schemas.repair import RepairCreate, RepairStatusUpdate, RepairUpdate @@ -158,11 +158,22 @@ class RepairRepository: return list( db.scalars( select(RepairStatusHistory) + .options(selectinload(RepairStatusHistory.actor)) .where(RepairStatusHistory.repair_id == repair_id) .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 def count_by_status(db: Session, status: str) -> int: return db.scalar(select(func.count(Repair.id)).where(Repair.status == status)) or 0 @@ -203,6 +214,110 @@ class RepairRepository: db.refresh(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 def _payload_data(payload: RepairCreate | RepairUpdate) -> dict: data = payload.model_dump() diff --git a/backend/hermes/app/schemas/repair.py b/backend/hermes/app/schemas/repair.py index 2f89adf..f086730 100644 --- a/backend/hermes/app/schemas/repair.py +++ b/backend/hermes/app/schemas/repair.py @@ -129,6 +129,8 @@ class RepairStatusHistoryResponse(BaseModel): new_status: str note: str | None actor_user_id: int | None + actor_username: str = "" + actor_display_name: str = "" created_at: datetime model_config = ConfigDict(from_attributes=True) @@ -160,3 +162,61 @@ class RepairIntakeResponse(BaseModel): repair_number: str status: RepairStatus 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] diff --git a/backend/hermes/app/services/audit_service.py b/backend/hermes/app/services/audit_service.py index 746d242..7044759 100644 --- a/backend/hermes/app/services/audit_service.py +++ b/backend/hermes/app/services/audit_service.py @@ -163,6 +163,8 @@ def action_title(action: str) -> str: "repairs.cancel": "Reparatur storniert", "repairs.intake_processed": "Reparatur-Intake verarbeitet", "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) diff --git a/backend/hermes/app/services/repair_notification_service.py b/backend/hermes/app/services/repair_notification_service.py new file mode 100644 index 0000000..92020b4 --- /dev/null +++ b/backend/hermes/app/services/repair_notification_service.py @@ -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), + ) diff --git a/backend/hermes/app/services/repair_public_link_service.py b/backend/hermes/app/services/repair_public_link_service.py new file mode 100644 index 0000000..5697582 --- /dev/null +++ b/backend/hermes/app/services/repair_public_link_service.py @@ -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, + ) diff --git a/frontend/athena/app/api/repairs/[id]/notifications/route.ts b/frontend/athena/app/api/repairs/[id]/notifications/route.ts new file mode 100644 index 0000000..5399859 --- /dev/null +++ b/frontend/athena/app/api/repairs/[id]/notifications/route.ts @@ -0,0 +1,14 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; + +type Params = { + params: Promise<{ + id: string; + }>; +}; + +export async function GET(request: NextRequest, { params }: Params) { + const { id } = await params; + return proxyHermesRequest(request, `/repairs/${id}/notifications`); +} diff --git a/frontend/athena/app/api/repairs/[id]/public-link/route.ts b/frontend/athena/app/api/repairs/[id]/public-link/route.ts new file mode 100644 index 0000000..ae2ae0d --- /dev/null +++ b/frontend/athena/app/api/repairs/[id]/public-link/route.ts @@ -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); +} diff --git a/frontend/athena/app/repairs/[id]/page.tsx b/frontend/athena/app/repairs/[id]/page.tsx index 3c877c5..4bcd88f 100644 --- a/frontend/athena/app/repairs/[id]/page.tsx +++ b/frontend/athena/app/repairs/[id]/page.tsx @@ -2,18 +2,26 @@ import { useCallback, useEffect, useState } from "react"; 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 { useToast } from "@/components/common/ToastProvider"; 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 { Button, buttonVariants } from "@/components/ui/button"; import { api } from "@/lib/api"; import { hasPermission } from "@/lib/permissions"; 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 = { 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) { const { showToast } = useToast(); const [repairId, setRepairId] = useState(""); const [repair, setRepair] = useState(null); const [history, setHistory] = useState([]); + const [publicLink, setPublicLink] = useState(null); + const [lastCreatedStatusPath, setLastCreatedStatusPath] = useState(""); + const [notifications, setNotifications] = useState(null); const [currentUser, setCurrentUser] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); @@ -56,6 +72,7 @@ export default function RepairDetailPage({ params }: Params) { const [statusOpen, setStatusOpen] = useState(false); const [formError, setFormError] = useState(""); const [statusError, setStatusError] = useState(""); + const [communicationPending, setCommunicationPending] = useState(false); useEffect(() => { params.then((resolved) => setRepairId(resolved.id)); @@ -74,6 +91,15 @@ export default function RepairDetailPage({ params }: Params) { setRepair(repairResponse.data); setHistory(historyResponse.data); setCurrentUser(meResponse.data); + const notificationResponse = await api.get(`/repairs/${repairId}/notifications`); + setNotifications(notificationResponse.data); + if (hasPermission(meResponse.data, "repairs.public_link.manage")) { + const publicLinkResponse = await api.get(`/repairs/${repairId}/public-link`); + setPublicLink(publicLinkResponse.data); + } else { + setPublicLink(null); + } + setLastCreatedStatusPath(""); } catch (err) { setError(getErrorMessage(err)); } finally { @@ -112,8 +138,10 @@ export default function RepairDetailPage({ params }: Params) { try { const response = await api.put(`/repairs/${repair.id}/status`, { status, note }); const historyResponse = await api.get(`/repairs/${repair.id}/history`); + const notificationResponse = await api.get(`/repairs/${repair.id}/notifications`); setRepair(response.data); setHistory(historyResponse.data); + setNotifications(notificationResponse.data); setStatusOpen(false); showToast({ type: "success", title: "Status aktualisiert", description: response.data.repair_number }); } catch (err) { @@ -135,6 +163,53 @@ export default function RepairDetailPage({ params }: Params) { const canUpdate = hasPermission(currentUser, "repairs.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(`/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(`/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 (
@@ -208,20 +283,90 @@ export default function RepairDetailPage({ params }: Params) { {history.length === 0 ? (

Noch keine Statushistorie vorhanden.

) : ( -
+
{history.map((item) => ( -
-
-

{item.old_status || "Start"} → {item.new_status}

+
+ +
+
+
+

{historyLabel(item)}

+ {item.new_status === repair.status && Aktuell} +
+

+ {item.actor_display_name || item.actor_username || "System"} +

+ {item.note &&

{item.note}

} +
- {item.note &&

{item.note}

}
))}
)} + +
+
+
+ {publicLink?.is_active ? : } +
+

Öffentlicher Statuslink {publicLink?.is_active ? "aktiv" : "inaktiv"}

+

+ Kunden erhalten später Statusupdates per E-Mail mit sicherem Link. Ein Kundenlogin ist dafür nicht erforderlich. +

+
+
+
+ + + +
+ {lastCreatedStatusPath && ( +
+

Einmaliger Klartextlink

+

{lastCreatedStatusPath}

+
+ )} + {canManagePublicLink ? ( +
+ + + +
+ ) : ( +

Für die Verwaltung des Statuslinks ist `repairs.public_link.manage` erforderlich.

+ )} +
+ +
+
+ + Vorbereitete Benachrichtigungen +
+
+ {notifications?.templates.map((template) => ( +
+
+

{template.subject}

+ {template.status && } +
+

{template.text}

+
+ ))} + {!notifications?.templates.length &&

Noch keine Vorlagen vorbereitet.

} +
+
+
+ +

Notification Events werden gespeichert, sobald später echter E-Mail-Versand angebunden wird. Interne Notizen werden nicht öffentlich ausgegeben.

+
+
+
+
+
+

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

Audit Logs öffnen diff --git a/frontend/athena/app/repairs/page.tsx b/frontend/athena/app/repairs/page.tsx index c09445f..199b755 100644 --- a/frontend/athena/app/repairs/page.tsx +++ b/frontend/athena/app/repairs/page.tsx @@ -10,7 +10,7 @@ import EmptyState from "@/components/common/EmptyState"; import SearchInput from "@/components/common/SearchInput"; import { useToast } from "@/components/common/ToastProvider"; 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 { Button, buttonVariants } from "@/components/ui/button"; import { api } from "@/lib/api"; @@ -212,7 +212,7 @@ export default function RepairsPage() { { setSearch(value); setPage(1); }} placeholder="Reparaturen suchen" />
diff --git a/frontend/athena/components/repairs/RepairFormDialog.tsx b/frontend/athena/components/repairs/RepairFormDialog.tsx index 73cbd62..5cb8bcc 100644 --- a/frontend/athena/components/repairs/RepairFormDialog.tsx +++ b/frontend/athena/components/repairs/RepairFormDialog.tsx @@ -12,6 +12,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; +import { priorityLabels, statusLabels } from "@/components/repairs/RepairStatusBadge"; import { api } from "@/lib/api"; import type { Customer } from "@/types/customer"; import type { Repair, RepairPayload, RepairPriority, RepairSource, RepairStatus } from "@/types/repair"; @@ -180,10 +181,10 @@ export default function RepairFormDialog({ open, repair, pending, serverError, o

Vorgang