feat(bug-reports): two-way replies (device-scoped) + admin reply endpoint

Reports were anonymous and one-way. Tie each report to the submitting device
(X-Device-Id), add an admin response back-channel, and let the app fetch replies for
its own device:
- migration 0023: bug_reports gains device_id, response, responded_at, response_seen.
- create_bug_report captures device_id.
- GET /bug-reports/mine (device-scoped) returns this device's reports + unseen count.
- POST /bug-reports/mine/seen clears the unseen flag.
- POST /bug-reports/{id}/reply sets the admin response (authored during triage).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jtrzupek 2026-06-12 11:35:44 +02:00
parent aebacc0389
commit d1f2f035b0
3 changed files with 143 additions and 1 deletions

View file

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

View file

@ -11,7 +11,7 @@ Screenshot to PNG/JPEG z react-native-view-shot, base64 — typowe rozmiary:
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from datetime import datetime from datetime import datetime, timezone
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
@ -19,6 +19,7 @@ from pydantic import BaseModel, Field
from sqlalchemy import desc, func, select from sqlalchemy import desc, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.device import get_device_id
from app.auth import require_api_key from app.auth import require_api_key
from app.db import get_session from app.db import get_session
from app.models.bug_report import BugReport from app.models.bug_report import BugReport
@ -56,10 +57,30 @@ class BugReportListOut(BaseModel):
total: int 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) @router.post("/bug-reports", status_code=status.HTTP_201_CREATED)
def create_bug_report( def create_bug_report(
payload: BugReportCreate, payload: BugReportCreate,
session: Annotated[Session, Depends(get_session)], session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> dict[str, str]: ) -> dict[str, str]:
# Smart-route entity_id: mobile Player używa `sceneId` param zarówno dla # 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 # scen jak i movies (legacy progress tracking hack). Bez tego INSERT FK
@ -75,6 +96,7 @@ def create_bug_report(
br = BugReport( br = BugReport(
id=uuid.uuid4(), id=uuid.uuid4(),
device_id=device_id,
message=payload.message.strip(), message=payload.message.strip(),
screen_name=payload.screen_name, screen_name=payload.screen_name,
app_version=payload.app_version, app_version=payload.app_version,
@ -87,6 +109,72 @@ def create_bug_report(
return {"id": str(br.id)} 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) @router.get("/bug-reports", response_model=BugReportListOut)
def list_bug_reports( def list_bug_reports(
session: Annotated[Session, Depends(get_session)], session: Annotated[Session, Depends(get_session)],

View file

@ -43,8 +43,19 @@ class BugReport(Base):
ForeignKey("movies.id", ondelete="SET NULL"), ForeignKey("movies.id", ondelete="SET NULL"),
nullable=True, 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) message: Mapped[str] = mapped_column(Text, nullable=False)
# PNG/JPEG bytes z react-native-view-shot, base64. Limit 1MB w API (per-request). # 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) screenshot_b64: Mapped[str | None] = mapped_column(Text, nullable=True)
# Po obejrzeniu/naprawieniu: True. Brak osobnej tabeli statusów — single-user app. # Po obejrzeniu/naprawieniu: True. Brak osobnej tabeli statusów — single-user app.
resolved: Mapped[bool] = mapped_column(default=False, nullable=False) 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)