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
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)],

View file

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