diff --git a/alembic/versions/20260612_0023_bug_report_replies.py b/alembic/versions/20260612_0023_bug_report_replies.py new file mode 100644 index 0000000..0af0eac --- /dev/null +++ b/alembic/versions/20260612_0023_bug_report_replies.py @@ -0,0 +1,43 @@ +"""bug report replies: device_id + admin response back-channel + +Revision ID: 0023_bug_report_replies +Revises: 0022_device_scoped_user_state +Create Date: 2026-06-12 + +Dwukierunkowy kanał na zgłoszenia: `device_id` wiąże zgłoszenie z urządzeniem (z +X-Device-Id), `response`/`responded_at` to odpowiedź admina, `response_seen` steruje +kropką na FAB (false = nieprzeczytana). Wszystko nullable/default — legacy reports OK. +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0023_bug_report_replies" +down_revision: str | None = "0022_device_scoped_user_state" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column("bug_reports", sa.Column("device_id", sa.String(length=64), nullable=True)) + op.add_column("bug_reports", sa.Column("response", sa.Text(), nullable=True)) + op.add_column( + "bug_reports", + sa.Column("responded_at", sa.DateTime(timezone=True), nullable=True), + ) + op.add_column( + "bug_reports", + sa.Column( + "response_seen", sa.Boolean(), nullable=False, server_default=sa.false() + ), + ) + op.create_index("ix_bug_reports_device_id", "bug_reports", ["device_id"]) + + +def downgrade() -> None: + op.drop_index("ix_bug_reports_device_id", table_name="bug_reports") + op.drop_column("bug_reports", "response_seen") + op.drop_column("bug_reports", "responded_at") + op.drop_column("bug_reports", "response") + op.drop_column("bug_reports", "device_id") diff --git a/app/api/bug_reports.py b/app/api/bug_reports.py index 42df868..547eb5e 100644 --- a/app/api/bug_reports.py +++ b/app/api/bug_reports.py @@ -11,7 +11,7 @@ Screenshot to PNG/JPEG z react-native-view-shot, base64 — typowe rozmiary: from __future__ import annotations import uuid -from datetime import datetime +from datetime import datetime, timezone from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, status @@ -19,6 +19,7 @@ from pydantic import BaseModel, Field from sqlalchemy import desc, func, select from sqlalchemy.orm import Session +from app.api.device import get_device_id from app.auth import require_api_key from app.db import get_session from app.models.bug_report import BugReport @@ -56,10 +57,30 @@ class BugReportListOut(BaseModel): total: int +class BugReplyIn(BaseModel): + response: str = Field(min_length=1, max_length=5000) + + +class MyBugReportOut(BaseModel): + id: uuid.UUID + created_at: datetime + screen_name: str | None + message: str + response: str | None + responded_at: datetime | None + response_seen: bool + + +class MyBugReportListOut(BaseModel): + items: list[MyBugReportOut] + unseen: int + + @router.post("/bug-reports", status_code=status.HTTP_201_CREATED) def create_bug_report( payload: BugReportCreate, session: Annotated[Session, Depends(get_session)], + device_id: Annotated[str, Depends(get_device_id)], ) -> dict[str, str]: # Smart-route entity_id: mobile Player używa `sceneId` param zarówno dla # scen jak i movies (legacy progress tracking hack). Bez tego INSERT FK @@ -75,6 +96,7 @@ def create_bug_report( br = BugReport( id=uuid.uuid4(), + device_id=device_id, message=payload.message.strip(), screen_name=payload.screen_name, app_version=payload.app_version, @@ -87,6 +109,72 @@ def create_bug_report( return {"id": str(br.id)} +@router.get("/bug-reports/mine", response_model=MyBugReportListOut) +def list_my_bug_reports( + session: Annotated[Session, Depends(get_session)], + device_id: Annotated[str, Depends(get_device_id)], +) -> MyBugReportListOut: + """Zgłoszenia TEGO urządzenia (+ ewentualna odpowiedź admina). Apka pokazuje je + w 'Your messages' pod FAB; `unseen` = liczba nieprzeczytanych odpowiedzi → kropka.""" + rows = session.scalars( + select(BugReport) + .where(BugReport.device_id == device_id) + .order_by(desc(BugReport.created_at)) + .limit(50) + ).all() + items = [ + MyBugReportOut( + id=r.id, + created_at=r.created_at, + screen_name=r.screen_name, + message=r.message, + response=r.response, + responded_at=r.responded_at, + response_seen=r.response_seen, + ) + for r in rows + ] + unseen = sum(1 for r in rows if r.response is not None and not r.response_seen) + return MyBugReportListOut(items=items, unseen=unseen) + + +@router.post("/bug-reports/mine/seen") +def mark_my_replies_seen( + session: Annotated[Session, Depends(get_session)], + device_id: Annotated[str, Depends(get_device_id)], +) -> dict[str, int]: + """Oznacz wszystkie odpowiedzi dla tego urządzenia jako przeczytane (gasi kropkę).""" + rows = session.scalars( + select(BugReport).where( + BugReport.device_id == device_id, + BugReport.response.is_not(None), + BugReport.response_seen.is_(False), + ) + ).all() + for r in rows: + r.response_seen = True + session.commit() + return {"marked": len(rows)} + + +@router.post("/bug-reports/{bug_id}/reply") +def reply_bug_report( + bug_id: uuid.UUID, + payload: BugReplyIn, + session: Annotated[Session, Depends(get_session)], +) -> dict[str, str]: + """Admin/triage: zapisz odpowiedź do usera. Pojawi się w jego 'Your messages' + (kropka na FAB). Resetuje response_seen, żeby user dostał notyfikację.""" + br = session.get(BugReport, bug_id) + if br is None: + raise HTTPException(status_code=404, detail="not found") + br.response = payload.response.strip() + br.responded_at = datetime.now(timezone.utc) + br.response_seen = False + session.commit() + return {"status": "replied"} + + @router.get("/bug-reports", response_model=BugReportListOut) def list_bug_reports( session: Annotated[Session, Depends(get_session)], diff --git a/app/models/bug_report.py b/app/models/bug_report.py index 7207001..f6bd7a5 100644 --- a/app/models/bug_report.py +++ b/app/models/bug_report.py @@ -43,8 +43,19 @@ class BugReport(Base): ForeignKey("movies.id", ondelete="SET NULL"), nullable=True, ) + # Device który wysłał zgłoszenie (X-Device-Id). Pozwala zaadresować odpowiedź + # admina z powrotem do TEGO urządzenia (GET /bug-reports/mine). Nullable — + # legacy reports sprzed device-trackingu i klienci bez headera. + device_id: Mapped[str | None] = mapped_column(String(64), nullable=True) message: Mapped[str] = mapped_column(Text, nullable=False) # PNG/JPEG bytes z react-native-view-shot, base64. Limit 1MB w API (per-request). screenshot_b64: Mapped[str | None] = mapped_column(Text, nullable=True) # Po obejrzeniu/naprawieniu: True. Brak osobnej tabeli statusów — single-user app. resolved: Mapped[bool] = mapped_column(default=False, nullable=False) + # Odpowiedź admina do usera (np. "to blok regionalny po Twojej stronie"). Apka + # pokazuje ją w "Your messages" pod FAB. response_seen=false → kropka na FAB. + response: Mapped[str | None] = mapped_column(Text, nullable=True) + responded_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + response_seen: Mapped[bool] = mapped_column(default=False, nullable=False)