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
|
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)],
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue