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:
parent
aebacc0389
commit
d1f2f035b0
3 changed files with 143 additions and 1 deletions
43
alembic/versions/20260612_0023_bug_report_replies.py
Normal file
43
alembic/versions/20260612_0023_bug_report_replies.py
Normal 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")
|
||||
|
|
@ -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)],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue