Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5af15087c | ||
|
|
a384b08044 | ||
|
|
ee4e845c89 | ||
|
|
977bb762c7 | ||
|
|
fe909bedcf | ||
|
|
9207bbcb8f |
@@ -210,12 +210,13 @@ Two static SPAs served directly by FastAPI (`main.py` mounts `app/static/` and a
|
|||||||
|
|
||||||
Both talk to the same JSON API; there's no admin/user API split beyond `require_admin`.
|
Both talk to the same JSON API; there's no admin/user API split beyond `require_admin`.
|
||||||
|
|
||||||
## Internationalization (`/` only)
|
## Internationalization (`/` and `/report-bug`)
|
||||||
|
|
||||||
`app/static/i18n.js` holds every user-facing string of `/` in 7 languages (en, it, es, fr, de, ru, zh) as one flat `TRANSLATIONS` table — no build step, no fetch, loaded before `app.js` so `t()` is always available. Language: `localStorage.plm_lang` → `navigator.language` → `en`. The switcher sits in the **chain-bar, not the navbar**, deliberately: the navbar is hidden until login, which would leave the landing page and login form untranslatable for exactly the users who need it.
|
`app/static/i18n.js` holds every user-facing string of `/` and `/report-bug` in 7 languages (en, it, es, fr, de, ru, zh) as one flat `TRANSLATIONS` table — no build step, no fetch. `/` loads it before `app.js`; `/report-bug` loads it before its own inline script — either way `t()` is always available by the time it's called. Language: `localStorage.plm_lang` → `navigator.language` → `en`, shared across both pages since they read/write the same `localStorage` key. On `/` the switcher sits in the **chain-bar, not the navbar**, deliberately: the navbar is hidden until login, which would leave the landing page and login form untranslatable for exactly the users who need it. `/report-bug` has no navbar at all, so its switcher is just a top-right bar of its own.
|
||||||
|
|
||||||
- Static markup is translated by attribute (`data-i18n`, plus `-html`, `-placeholder`, `-title`, `-aria-label`, `-alt`) via `applyStaticTranslations(root?)`; anything rendered from server data uses `t()` in `app.js` and is re-rendered by `onLanguageChange()`. An element belongs to one camp or the other, **never both**, or the two mechanisms overwrite each other — that's why `#bet-btn` has no `data-i18n`: its label carries the configurable bet amount, so `renderBetButton()` owns it.
|
- Static markup is translated by attribute (`data-i18n`, plus `-html`, `-placeholder`, `-title`, `-aria-label`, `-alt`) via `applyStaticTranslations(root?)`; anything rendered from server data uses `t()` directly (`app.js`'s `onLanguageChange()`, `report-bug.html`'s own inline equivalent) and is re-rendered on a language switch. An element belongs to one camp or the other, **never both**, or the two mechanisms overwrite each other — that's why `#bet-btn` has no `data-i18n`: its label carries the configurable bet amount, so `renderBetButton()` owns it.
|
||||||
- **Every language must have exactly the same key set.** There is no fallback beyond `en`; a missing key renders as the raw key string.
|
- **Every language must have exactly the same key set.** There is no fallback beyond `en`; a missing key renders as the raw key string.
|
||||||
|
- `/report-bug`'s `bugReport.englishNotice` string is itself translated into all 7 languages — it just always *says*, in whichever language the visitor reads, to write the actual bug description in English (so the admin panel, which is Italian-operator-facing and untranslated, doesn't end up with reports in 7 different languages).
|
||||||
- `/admin` is intentionally untranslated (operator-facing, Italian), as is `/guida`.
|
- `/admin` is intentionally untranslated (operator-facing, Italian), as is `/guida`.
|
||||||
|
|
||||||
**API error contract** (`app/api/errors.py`) — the API is single-language by design. Failures answer with a structured `detail`: `{"code", "message", "params"}`, where `message` is English for non-dashboard consumers and `code` is what the frontend maps to `error.<code>` in `i18n.js` (falling back to `message` for an unknown code). `BetError`/`WithdrawalError` subclass `ApiError` and carry the code from where the failure happens. Even the catch-all 500 handler answers in that shape (`internal_error`), so clients never special-case unexpected errors, and the exception text stays in `logs/app.log`. Adding a user-facing error: give it a code, add `error.<code>` to all 7 languages, and pass values through `params` (amounts as `*_sats` — the frontend derives a `*_plm` sibling) instead of baking them into English text.
|
**API error contract** (`app/api/errors.py`) — the API is single-language by design. Failures answer with a structured `detail`: `{"code", "message", "params"}`, where `message` is English for non-dashboard consumers and `code` is what the frontend maps to `error.<code>` in `i18n.js` (falling back to `message` for an unknown code). `BetError`/`WithdrawalError` subclass `ApiError` and carry the code from where the failure happens. Even the catch-all 500 handler answers in that shape (`internal_error`), so clients never special-case unexpected errors, and the exception text stays in `logs/app.log`. Adding a user-facing error: give it a code, add `error.<code>` to all 7 languages, and pass values through `params` (amounts as `*_sats` — the frontend derives a `*_plm` sibling) instead of baking them into English text.
|
||||||
|
|||||||
+72
-1
@@ -10,7 +10,7 @@ from app.api.timeutil import isoformat_utc
|
|||||||
from app.audit.log import write_audit_log
|
from app.audit.log import write_audit_log
|
||||||
from app.auth.security import hash_password
|
from app.auth.security import hash_password
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db.models import AuditLog, PendingTransaction, Round, User
|
from app.db.models import AuditLog, BugReport, PendingTransaction, Round, User
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.rounds.config import get_round_config
|
from app.rounds.config import get_round_config
|
||||||
from app.wallet.address import is_valid_plm_address
|
from app.wallet.address import is_valid_plm_address
|
||||||
@@ -346,3 +346,74 @@ async def list_pending_transactions(
|
|||||||
)
|
)
|
||||||
for p in entries
|
for p in entries
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
_BUG_REPORT_STATUSES = ("open", "read", "resolved")
|
||||||
|
|
||||||
|
|
||||||
|
class AdminBugReportResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
description: str
|
||||||
|
contact: str | None
|
||||||
|
user_id: int | None
|
||||||
|
username: str | None
|
||||||
|
status: str
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
def _bug_report_response(report: BugReport, username: str | None) -> AdminBugReportResponse:
|
||||||
|
return AdminBugReportResponse(
|
||||||
|
id=report.id,
|
||||||
|
description=report.description,
|
||||||
|
contact=report.contact,
|
||||||
|
user_id=report.user_id,
|
||||||
|
username=username,
|
||||||
|
status=report.status,
|
||||||
|
created_at=isoformat_utc(report.created_at),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/bug-reports", response_model=list[AdminBugReportResponse], dependencies=[Depends(require_admin)]
|
||||||
|
)
|
||||||
|
async def list_bug_reports(
|
||||||
|
session: AsyncSession = Depends(get_session), limit: int = Query(default=200, ge=1, le=500)
|
||||||
|
) -> list[AdminBugReportResponse]:
|
||||||
|
reports = (await session.scalars(select(BugReport).order_by(BugReport.id.desc()).limit(limit))).all()
|
||||||
|
user_ids = {r.user_id for r in reports if r.user_id is not None}
|
||||||
|
usernames = {}
|
||||||
|
if user_ids:
|
||||||
|
users = (await session.scalars(select(User).where(User.id.in_(user_ids)))).all()
|
||||||
|
usernames = {u.id: u.username for u in users}
|
||||||
|
|
||||||
|
return [
|
||||||
|
_bug_report_response(r, usernames.get(r.user_id) if r.user_id is not None else None)
|
||||||
|
for r in reports
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class BugReportStatusUpdate(BaseModel):
|
||||||
|
status: str = Field(pattern="^(open|read|resolved)$")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/bug-reports/{report_id}/status",
|
||||||
|
response_model=AdminBugReportResponse,
|
||||||
|
dependencies=[Depends(require_admin)],
|
||||||
|
)
|
||||||
|
async def update_bug_report_status(
|
||||||
|
report_id: int, body: BugReportStatusUpdate, session: AsyncSession = Depends(get_session)
|
||||||
|
) -> AdminBugReportResponse:
|
||||||
|
report = await session.get(BugReport, report_id)
|
||||||
|
if report is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "bug report not found")
|
||||||
|
|
||||||
|
report.status = body.status
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
username = None
|
||||||
|
if report.user_id is not None:
|
||||||
|
user = await session.get(User, report.user_id)
|
||||||
|
username = user.username if user is not None else None
|
||||||
|
|
||||||
|
return _bug_report_response(report, username)
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
from fastapi import APIRouter, Depends, status
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.timeutil import isoformat_utc
|
||||||
|
from app.auth.dependencies import get_current_user, get_optional_user
|
||||||
|
from app.db.models import BugReport, User
|
||||||
|
from app.db.session import get_session
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/bug-reports", tags=["bug-reports"])
|
||||||
|
|
||||||
|
|
||||||
|
class BugReportCreate(BaseModel):
|
||||||
|
description: str = Field(min_length=1, max_length=2000)
|
||||||
|
contact: str | None = Field(default=None, max_length=256)
|
||||||
|
|
||||||
|
@field_validator("description")
|
||||||
|
@classmethod
|
||||||
|
def _description_not_blank(cls, value: str) -> str:
|
||||||
|
value = value.strip()
|
||||||
|
if not value:
|
||||||
|
raise ValueError("description must not be blank")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("contact")
|
||||||
|
@classmethod
|
||||||
|
def _contact_stripped(cls, value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
value = value.strip()
|
||||||
|
return value or None
|
||||||
|
|
||||||
|
|
||||||
|
class BugReportResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=BugReportResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_bug_report(
|
||||||
|
body: BugReportCreate,
|
||||||
|
user: User | None = Depends(get_optional_user),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> BugReportResponse:
|
||||||
|
report = BugReport(
|
||||||
|
description=body.description,
|
||||||
|
contact=body.contact,
|
||||||
|
user_id=user.id if user is not None else None,
|
||||||
|
)
|
||||||
|
session.add(report)
|
||||||
|
await session.commit()
|
||||||
|
return BugReportResponse(id=report.id)
|
||||||
|
|
||||||
|
|
||||||
|
class MyBugReportResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
description: str
|
||||||
|
status: str
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mine", response_model=list[MyBugReportResponse])
|
||||||
|
async def list_my_bug_reports(
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> list[MyBugReportResponse]:
|
||||||
|
"""The one user-facing history view for bug reports (anonymous submissions have
|
||||||
|
no user to attribute this to, so this only ever covers ones filed while logged in)."""
|
||||||
|
reports = (
|
||||||
|
await session.scalars(
|
||||||
|
select(BugReport).where(BugReport.user_id == user.id).order_by(BugReport.id.desc())
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return [
|
||||||
|
MyBugReportResponse(
|
||||||
|
id=r.id, description=r.description, status=r.status, created_at=isoformat_utc(r.created_at)
|
||||||
|
)
|
||||||
|
for r in reports
|
||||||
|
]
|
||||||
@@ -15,7 +15,7 @@ from app.db.models import RoundParticipant, User
|
|||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.rounds.config import get_round_config
|
from app.rounds.config import get_round_config
|
||||||
from app.rounds.events import EVICTED, RoundEventCapacityError, broadcaster
|
from app.rounds.events import EVICTED, RoundEventCapacityError, broadcaster
|
||||||
from app.rounds.service import get_active_round
|
from app.rounds.service import get_active_round, winner_share
|
||||||
|
|
||||||
router = APIRouter(prefix="/rounds", tags=["rounds"])
|
router = APIRouter(prefix="/rounds", tags=["rounds"])
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ async def current_round(
|
|||||||
# upper bound by the payout tx's own fee, which is deducted from the winner's
|
# upper bound by the payout tx's own fee, which is deducted from the winner's
|
||||||
# share and isn't knowable until the payout is built — a few hundred sat on a
|
# share and isn't knowable until the payout is built — a few hundred sat on a
|
||||||
# 1 sat/vB payout, i.e. invisible at PLM amounts, but it is not exact.
|
# 1 sat/vB payout, i.e. invisible at PLM amounts, but it is not exact.
|
||||||
jackpot_sats = pool_amount_sats * 70 // 100
|
jackpot_sats = winner_share(pool_amount_sats)
|
||||||
|
|
||||||
return CurrentRoundResponse(
|
return CurrentRoundResponse(
|
||||||
server_time=datetime.now(timezone.utc).isoformat(),
|
server_time=datetime.now(timezone.utc).isoformat(),
|
||||||
|
|||||||
@@ -188,6 +188,24 @@ class Withdrawal(Base):
|
|||||||
confirmed_at: Mapped[datetime | None] = mapped_column(default=None)
|
confirmed_at: Mapped[datetime | None] = mapped_column(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
class BugReport(Base):
|
||||||
|
__tablename__ = "bug_reports"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
description: Mapped[str] = mapped_column(Text)
|
||||||
|
contact: Mapped[str | None] = mapped_column(String(256), default=None)
|
||||||
|
# Set when the reporter was logged in at submission time; the report page is
|
||||||
|
# reachable both logged-in and logged-out (like GET /rounds/current), so this
|
||||||
|
# stays nullable rather than requiring auth just to file a report.
|
||||||
|
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), default=None)
|
||||||
|
# open -> read -> resolved, admin-driven (app/api/routes/admin.py). "read" is a
|
||||||
|
# distinct step from "resolved" so a reporter checking their own status (only
|
||||||
|
# possible when logged in — see GET /bug-reports/mine) can tell "an admin has
|
||||||
|
# seen this" apart from "this has actually been fixed".
|
||||||
|
status: Mapped[str] = mapped_column(String(16), default="open")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(default=utcnow)
|
||||||
|
|
||||||
|
|
||||||
class AuditLog(Base):
|
class AuditLog(Base):
|
||||||
__tablename__ = "audit_log"
|
__tablename__ = "audit_log"
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import app.rounds.confirmation # noqa: F401 (registers the "payout" confirmati
|
|||||||
import app.withdrawals.confirmation # noqa: F401 (registers the "withdrawal" confirmation handler)
|
import app.withdrawals.confirmation # noqa: F401 (registers the "withdrawal" confirmation handler)
|
||||||
from app.api.routes.admin import router as admin_router
|
from app.api.routes.admin import router as admin_router
|
||||||
from app.api.routes.bets import router as bets_router
|
from app.api.routes.bets import router as bets_router
|
||||||
|
from app.api.routes.bug_reports import router as bug_reports_router
|
||||||
from app.api.routes.qr import router as qr_router
|
from app.api.routes.qr import router as qr_router
|
||||||
from app.api.routes.rounds import router as rounds_router
|
from app.api.routes.rounds import router as rounds_router
|
||||||
from app.api.routes.users import router as users_router
|
from app.api.routes.users import router as users_router
|
||||||
@@ -99,6 +100,7 @@ app.include_router(users_router)
|
|||||||
app.include_router(bets_router)
|
app.include_router(bets_router)
|
||||||
app.include_router(withdrawals_router)
|
app.include_router(withdrawals_router)
|
||||||
app.include_router(admin_router)
|
app.include_router(admin_router)
|
||||||
|
app.include_router(bug_reports_router)
|
||||||
app.include_router(qr_router)
|
app.include_router(qr_router)
|
||||||
app.include_router(rounds_router)
|
app.include_router(rounds_router)
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from app.electrum.scripthash import address_to_scripthash
|
|||||||
from app.rounds.config import get_round_config
|
from app.rounds.config import get_round_config
|
||||||
from app.rounds.draw import draw_winner, header_hex_to_block_hash
|
from app.rounds.draw import draw_winner, header_hex_to_block_hash
|
||||||
from app.rounds.events import broadcaster
|
from app.rounds.events import broadcaster
|
||||||
from app.rounds.service import open_new_round_if_needed
|
from app.rounds.service import open_new_round_if_needed, winner_share
|
||||||
from app.wallet.hd import derive_pool_key
|
from app.wallet.hd import derive_pool_key
|
||||||
from app.wallet.plm_network import PLM_MAINNET
|
from app.wallet.plm_network import PLM_MAINNET
|
||||||
from app.wallet.psbt_builder import InsufficientFundsError, Utxo, build_payout_transaction
|
from app.wallet.psbt_builder import InsufficientFundsError, Utxo, build_payout_transaction
|
||||||
@@ -338,8 +338,8 @@ class RoundScheduler:
|
|||||||
await self._log_payout_failure(round_id, winner_user_id, "winner user not found")
|
await self._log_payout_failure(round_id, winner_user_id, "winner user not found")
|
||||||
return
|
return
|
||||||
|
|
||||||
winner_share = pool_amount_sats * 70 // 100
|
winner_sats = winner_share(pool_amount_sats)
|
||||||
commission_share = pool_amount_sats - winner_share # remainder from rounding goes to fees
|
commission_share = pool_amount_sats - winner_sats # remainder from rounding goes to fees
|
||||||
|
|
||||||
# --- Phase 2: build (network read only, no DB write yet) -----------------
|
# --- Phase 2: build (network read only, no DB write yet) -----------------
|
||||||
try:
|
try:
|
||||||
@@ -358,7 +358,7 @@ class RoundScheduler:
|
|||||||
from_script=pool_script_obj,
|
from_script=pool_script_obj,
|
||||||
utxos=utxos,
|
utxos=utxos,
|
||||||
winner_address=winner_address,
|
winner_address=winner_address,
|
||||||
winner_share_sats=winner_share,
|
winner_share_sats=winner_sats,
|
||||||
fee_address=fee_address,
|
fee_address=fee_address,
|
||||||
commission_sats=commission_share,
|
commission_sats=commission_share,
|
||||||
change_address=pool_address,
|
change_address=pool_address,
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ _ACTIVE_STATUSES = ("open", "closing", "drawing", "paying_out")
|
|||||||
# broken in a way we don't anticipate.
|
# broken in a way we don't anticipate.
|
||||||
_OPEN_ROUND_ATTEMPTS = 3
|
_OPEN_ROUND_ATTEMPTS = 3
|
||||||
|
|
||||||
|
# 70% winner / 30% fees. Hardcoded by design (see CLAUDE.md) — changing the split
|
||||||
|
# is a code change, not an admin-editable setting. Single source of truth so the
|
||||||
|
# advertised jackpot (rounds.py) and the actual payout (scheduler.py) can't diverge.
|
||||||
|
|
||||||
|
|
||||||
|
def winner_share(pool_amount_sats: int) -> int:
|
||||||
|
return pool_amount_sats * 70 // 100
|
||||||
|
|
||||||
|
|
||||||
async def get_active_round(session: AsyncSession) -> Round | None:
|
async def get_active_round(session: AsyncSession) -> Round | None:
|
||||||
"""The round currently in progress (in any non-closed state), if any. Rounds
|
"""The round currently in progress (in any non-closed state), if any. Rounds
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
|||||||
th, td { text-align: left; padding: 8px 6px; border-bottom: 1px solid var(--color-border); vertical-align: top; }
|
th, td { text-align: left; padding: 8px 6px; border-bottom: 1px solid var(--color-border); vertical-align: top; }
|
||||||
th { color: var(--color-muted-foreground); font-weight: 500; }
|
th { color: var(--color-muted-foreground); font-weight: 500; }
|
||||||
td.addr, td.txid { font-family: 'Fira Code', monospace; word-break: break-all; max-width: 200px; }
|
td.addr, td.txid { font-family: 'Fira Code', monospace; word-break: break-all; max-width: 200px; }
|
||||||
|
td.payload-cell { max-width: 360px; white-space: pre-wrap; word-break: break-word; }
|
||||||
.table-wrap { overflow-x: auto; }
|
.table-wrap { overflow-x: auto; }
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
@@ -144,6 +145,13 @@ td.addr, td.txid { font-family: 'Fira Code', monospace; word-break: break-all; m
|
|||||||
background: #FEF3C7; color: #92400E; border-color: #F59E0B;
|
background: #FEF3C7; color: #92400E; border-color: #F59E0B;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge.bug-status-open { background: #FEF3C7; color: #92400E; border-color: #F59E0B; }
|
||||||
|
.badge.bug-status-read { background: var(--color-background); color: var(--color-muted-foreground); }
|
||||||
|
.badge.bug-status-resolved { background: var(--color-success-bg); color: var(--color-success); border-color: var(--color-success); }
|
||||||
|
|
||||||
|
.bug-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
|
||||||
|
.bug-actions button { width: auto; margin-top: 0; min-height: 30px; padding: 4px 10px; font-size: 0.78rem; }
|
||||||
|
|
||||||
button.reveal {
|
button.reveal {
|
||||||
width: auto; margin-top: 0; padding: 4px 10px; min-height: 30px; font-size: 0.78rem;
|
width: auto; margin-top: 0; padding: 4px 10px; min-height: 30px; font-size: 0.78rem;
|
||||||
background: var(--color-destructive-bg); color: var(--color-destructive); border: 1px solid var(--color-destructive);
|
background: var(--color-destructive-bg); color: var(--color-destructive); border: 1px solid var(--color-destructive);
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<span class="nav-tab" id="nav-round" onclick="switchView('round')">Round</span>
|
<span class="nav-tab" id="nav-round" onclick="switchView('round')">Round</span>
|
||||||
<span class="nav-tab" id="nav-pending" onclick="switchView('pending')">Transazioni pendenti</span>
|
<span class="nav-tab" id="nav-pending" onclick="switchView('pending')">Transazioni pendenti</span>
|
||||||
<span class="nav-tab" id="nav-audit" onclick="switchView('audit')">Audit log</span>
|
<span class="nav-tab" id="nav-audit" onclick="switchView('audit')">Audit log</span>
|
||||||
|
<span class="nav-tab" id="nav-bugreports" onclick="switchView('bugreports')">Segnalazioni bug</span>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<span class="chain-status-pill">
|
<span class="chain-status-pill">
|
||||||
<span class="status-dot" id="chain-status-dot"></span>
|
<span class="status-dot" id="chain-status-dot"></span>
|
||||||
@@ -153,6 +154,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="view" id="view-bugreports">
|
||||||
|
<h2 class="section-title">Segnalazioni bug</h2>
|
||||||
|
<p class="hint">Segnalazioni inviate dagli utenti tramite la pagina "Segnala un bug".</p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>ID</th><th>Descrizione</th><th>Contatto</th><th>Utente</th><th>Quando</th><th>Stato</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="bugreports-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+46
-3
@@ -89,8 +89,8 @@ function stopChainStatusPolling() {
|
|||||||
chainStatusInterval = null;
|
chainStatusInterval = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VIEWS = ['parametri', 'utenti', 'round', 'pending', 'audit'];
|
const VIEWS = ['parametri', 'utenti', 'round', 'pending', 'audit', 'bugreports'];
|
||||||
const VIEW_LOADERS = { utenti: loadUsers, round: loadRounds, pending: loadPending, audit: loadAuditLog };
|
const VIEW_LOADERS = { utenti: loadUsers, round: loadRounds, pending: loadPending, audit: loadAuditLog, bugreports: loadBugReports };
|
||||||
let currentAdminView = 'parametri';
|
let currentAdminView = 'parametri';
|
||||||
|
|
||||||
function switchView(name) {
|
function switchView(name) {
|
||||||
@@ -109,7 +109,7 @@ function showDashboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadDashboard() {
|
async function loadDashboard() {
|
||||||
await Promise.all([adminLoadConfig(), loadUsers(), loadRounds(), loadPending(), loadAuditLog()]);
|
await Promise.all([adminLoadConfig(), loadUsers(), loadRounds(), loadPending(), loadAuditLog(), loadBugReports()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function adminLogin() {
|
async function adminLogin() {
|
||||||
@@ -346,6 +346,49 @@ async function loadAuditLog() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BUG_REPORT_STATUS_LABELS = { open: 'Da leggere', read: 'Presa in carico', resolved: 'Risolta' };
|
||||||
|
|
||||||
|
function bugReportBadge(status) {
|
||||||
|
return `<span class="badge bug-status-${escapeHtml(status)}">${escapeHtml(BUG_REPORT_STATUS_LABELS[status] || status)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBugReports() {
|
||||||
|
try {
|
||||||
|
const reports = await callAdmin('GET', '/admin/bug-reports');
|
||||||
|
const tbody = document.getElementById('bugreports-tbody');
|
||||||
|
tbody.innerHTML = reports.map((r) => `
|
||||||
|
<tr>
|
||||||
|
<td>${r.id}</td>
|
||||||
|
<td class="payload-cell">${escapeHtml(r.description)}</td>
|
||||||
|
<td>${r.contact ? escapeHtml(r.contact) : '—'}</td>
|
||||||
|
<td>${r.username ? escapeHtml(r.username) : '—'}</td>
|
||||||
|
<td>${fmtDate(r.created_at)}</td>
|
||||||
|
<td>
|
||||||
|
${bugReportBadge(r.status)}
|
||||||
|
<div class="bug-actions">
|
||||||
|
${r.status === 'open' ? `<button class="secondary" onclick="setBugReportStatus(${r.id}, 'read', this)">Segna come presa in carico</button>` : ''}
|
||||||
|
${r.status !== 'resolved' ? `<button class="secondary" onclick="setBugReportStatus(${r.id}, 'resolved', this)">Segna come risolta</button>` : ''}
|
||||||
|
${r.status !== 'open' ? `<button class="secondary" onclick="setBugReportStatus(${r.id}, 'open', this)">Riapri</button>` : ''}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('') || '<tr><td colspan="6" class="hint">Nessuna segnalazione ricevuta.</td></tr>';
|
||||||
|
} catch (e) {
|
||||||
|
toast('Errore nel caricamento segnalazioni: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setBugReportStatus(reportId, newStatus, button) {
|
||||||
|
await withLoading(button, '…', async () => {
|
||||||
|
try {
|
||||||
|
await callAdmin('POST', '/admin/bug-reports/' + reportId + '/status', { status: newStatus });
|
||||||
|
await loadBugReports();
|
||||||
|
} catch (e) {
|
||||||
|
toast('Errore: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('admin-token').addEventListener('keydown', (e) => {
|
document.getElementById('admin-token').addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Enter') adminLogin();
|
if (e.key === 'Enter') adminLogin();
|
||||||
});
|
});
|
||||||
|
|||||||
+12
-2
@@ -488,11 +488,21 @@ async function refreshRound() {
|
|||||||
// myUserId may not be loaded yet on the very first tick after a reload
|
// myUserId may not be loaded yet on the very first tick after a reload
|
||||||
// (refreshMe() and refreshRound() run concurrently) — fall back to the
|
// (refreshMe() and refreshRound() run concurrently) — fall back to the
|
||||||
// persisted result rather than risk showing nothing or the wrong side.
|
// persisted result rather than risk showing nothing or the wrong side.
|
||||||
|
// winner_user_id is committed as soon as the draw picks a winner, but
|
||||||
|
// winner_amount_sats isn't set until the payout tx is built afterwards
|
||||||
|
// (a real Electrum round-trip later) — revealing a win before then would
|
||||||
|
// show "+— PLM". Only the winner's own reveal needs to wait for it.
|
||||||
|
const iWon = myUserId != null && data.winner_user_id === myUserId;
|
||||||
|
const amountReady = !iWon || data.winner_amount_sats != null;
|
||||||
const canReveal =
|
const canReveal =
|
||||||
data.user_played && data.winner_user_id != null && (alreadyKnown || elapsedMs >= minMs) && myUserId != null;
|
data.user_played &&
|
||||||
|
data.winner_user_id != null &&
|
||||||
|
(alreadyKnown || elapsedMs >= minMs) &&
|
||||||
|
myUserId != null &&
|
||||||
|
amountReady;
|
||||||
|
|
||||||
if (canReveal) {
|
if (canReveal) {
|
||||||
const won = data.winner_user_id === myUserId;
|
const won = iWon;
|
||||||
if (!alreadyKnown) {
|
if (!alreadyKnown) {
|
||||||
persistResult(data.round_id, won, data.winner_amount_sats);
|
persistResult(data.round_id, won, data.winner_amount_sats);
|
||||||
if (won) {
|
if (won) {
|
||||||
|
|||||||
@@ -10,6 +10,26 @@ const TRANSLATIONS = {
|
|||||||
'nav.guideTitle': 'Guide',
|
'nav.guideTitle': 'Guide',
|
||||||
'nav.guideAria': 'Open the user guide',
|
'nav.guideAria': 'Open the user guide',
|
||||||
'nav.bugReport': 'Report a bug',
|
'nav.bugReport': 'Report a bug',
|
||||||
|
'bugReport.pageTitle': 'Report a bug',
|
||||||
|
'bugReport.heading': 'Report a bug',
|
||||||
|
'bugReport.intro': 'Found a problem? Describe it below — your report goes straight to the admin panel.',
|
||||||
|
'bugReport.englishNotice': "Please write your bug report in English, regardless of the language you're browsing in — this helps us handle it faster.",
|
||||||
|
'bugReport.descriptionLabel': 'What happened?',
|
||||||
|
'bugReport.descriptionPlaceholder': 'Describe the bug: what you were doing, what you expected, and what happened instead.',
|
||||||
|
'bugReport.contactLabel': 'Contact (optional)',
|
||||||
|
'bugReport.contactPlaceholder': "Email or other contact, if you'd like a reply",
|
||||||
|
'bugReport.submitBtn': 'Send report',
|
||||||
|
'bugReport.submitting': 'Sending…',
|
||||||
|
'bugReport.blankError': 'Describe the bug before sending.',
|
||||||
|
'bugReport.successToast': 'Thanks! Report sent.',
|
||||||
|
'bugReport.errorPrefix': 'Error sending: ',
|
||||||
|
'bugReport.myReportsTitle': 'Your reports',
|
||||||
|
'bugReport.myReportsHint': 'Only reports sent from this account, with the status set by the admin team.',
|
||||||
|
'bugReport.myReportsEmpty': "You haven't sent any reports yet.",
|
||||||
|
'bugReport.statusOpen': 'Not read yet',
|
||||||
|
'bugReport.statusRead': 'Acknowledged',
|
||||||
|
'bugReport.statusResolved': 'Resolved',
|
||||||
|
'bugReport.backLink': 'Back to home',
|
||||||
'nav.logoutTitle': 'Log out',
|
'nav.logoutTitle': 'Log out',
|
||||||
'nav.logoutAria': 'Log out of your account',
|
'nav.logoutAria': 'Log out of your account',
|
||||||
'nav.deposit': 'Deposit',
|
'nav.deposit': 'Deposit',
|
||||||
@@ -153,6 +173,26 @@ const TRANSLATIONS = {
|
|||||||
'nav.guideTitle': 'Guida',
|
'nav.guideTitle': 'Guida',
|
||||||
'nav.guideAria': 'Apri la guida utente',
|
'nav.guideAria': 'Apri la guida utente',
|
||||||
'nav.bugReport': 'Segnala un bug',
|
'nav.bugReport': 'Segnala un bug',
|
||||||
|
'bugReport.pageTitle': 'Segnala un bug',
|
||||||
|
'bugReport.heading': 'Segnala un bug',
|
||||||
|
'bugReport.intro': 'Hai trovato un problema? Descrivilo qui sotto: la segnalazione arriva direttamente al pannello di amministrazione.',
|
||||||
|
'bugReport.englishNotice': "Scrivi la segnalazione in inglese, indipendentemente dalla lingua che stai usando per navigare: questo ci aiuta a gestirla più velocemente.",
|
||||||
|
'bugReport.descriptionLabel': 'Cosa è successo?',
|
||||||
|
'bugReport.descriptionPlaceholder': 'Descrivi il bug: cosa stavi facendo, cosa ti aspettavi e cosa è successo invece.',
|
||||||
|
'bugReport.contactLabel': 'Contatto (opzionale)',
|
||||||
|
'bugReport.contactPlaceholder': 'Email o altro recapito, se vuoi essere ricontattato',
|
||||||
|
'bugReport.submitBtn': 'Invia segnalazione',
|
||||||
|
'bugReport.submitting': 'Invio…',
|
||||||
|
'bugReport.blankError': 'Descrivi il bug prima di inviare.',
|
||||||
|
'bugReport.successToast': 'Grazie! Segnalazione inviata.',
|
||||||
|
'bugReport.errorPrefix': "Errore nell'invio: ",
|
||||||
|
'bugReport.myReportsTitle': 'Le tue segnalazioni',
|
||||||
|
'bugReport.myReportsHint': "Solo le segnalazioni inviate da questo account, con lo stato aggiornato dall'amministrazione.",
|
||||||
|
'bugReport.myReportsEmpty': 'Non hai ancora inviato segnalazioni.',
|
||||||
|
'bugReport.statusOpen': 'Da leggere',
|
||||||
|
'bugReport.statusRead': 'Presa in carico',
|
||||||
|
'bugReport.statusResolved': 'Risolta',
|
||||||
|
'bugReport.backLink': 'Torna alla home',
|
||||||
'nav.logoutTitle': 'Esci',
|
'nav.logoutTitle': 'Esci',
|
||||||
'nav.logoutAria': "Esci dall'account",
|
'nav.logoutAria': "Esci dall'account",
|
||||||
'nav.deposit': 'Deposito',
|
'nav.deposit': 'Deposito',
|
||||||
@@ -293,6 +333,26 @@ const TRANSLATIONS = {
|
|||||||
'nav.guideTitle': 'Guía',
|
'nav.guideTitle': 'Guía',
|
||||||
'nav.guideAria': 'Abrir la guía del usuario',
|
'nav.guideAria': 'Abrir la guía del usuario',
|
||||||
'nav.bugReport': 'Reportar un error',
|
'nav.bugReport': 'Reportar un error',
|
||||||
|
'bugReport.pageTitle': 'Reportar un error',
|
||||||
|
'bugReport.heading': 'Reportar un error',
|
||||||
|
'bugReport.intro': '¿Encontraste un problema? Descríbelo a continuación: el informe llega directamente al panel de administración.',
|
||||||
|
'bugReport.englishNotice': 'Escribe el informe en inglés, independientemente del idioma que estés usando para navegar: esto nos ayuda a gestionarlo más rápido.',
|
||||||
|
'bugReport.descriptionLabel': '¿Qué pasó?',
|
||||||
|
'bugReport.descriptionPlaceholder': 'Describe el error: qué estabas haciendo, qué esperabas y qué sucedió en su lugar.',
|
||||||
|
'bugReport.contactLabel': 'Contacto (opcional)',
|
||||||
|
'bugReport.contactPlaceholder': 'Correo u otro contacto, si quieres que te respondamos',
|
||||||
|
'bugReport.submitBtn': 'Enviar informe',
|
||||||
|
'bugReport.submitting': 'Enviando…',
|
||||||
|
'bugReport.blankError': 'Describe el error antes de enviarlo.',
|
||||||
|
'bugReport.successToast': '¡Gracias! Informe enviado.',
|
||||||
|
'bugReport.errorPrefix': 'Error al enviar: ',
|
||||||
|
'bugReport.myReportsTitle': 'Tus informes',
|
||||||
|
'bugReport.myReportsHint': 'Solo los informes enviados desde esta cuenta, con el estado actualizado por el equipo de administración.',
|
||||||
|
'bugReport.myReportsEmpty': 'Todavía no has enviado ningún informe.',
|
||||||
|
'bugReport.statusOpen': 'Sin leer',
|
||||||
|
'bugReport.statusRead': 'En curso',
|
||||||
|
'bugReport.statusResolved': 'Resuelto',
|
||||||
|
'bugReport.backLink': 'Volver al inicio',
|
||||||
'nav.logoutTitle': 'Salir',
|
'nav.logoutTitle': 'Salir',
|
||||||
'nav.logoutAria': 'Cerrar sesión',
|
'nav.logoutAria': 'Cerrar sesión',
|
||||||
'nav.deposit': 'Depósito',
|
'nav.deposit': 'Depósito',
|
||||||
@@ -433,6 +493,26 @@ const TRANSLATIONS = {
|
|||||||
'nav.guideTitle': 'Guide',
|
'nav.guideTitle': 'Guide',
|
||||||
'nav.guideAria': "Ouvrir le guide de l'utilisateur",
|
'nav.guideAria': "Ouvrir le guide de l'utilisateur",
|
||||||
'nav.bugReport': 'Signaler un bug',
|
'nav.bugReport': 'Signaler un bug',
|
||||||
|
'bugReport.pageTitle': 'Signaler un bug',
|
||||||
|
'bugReport.heading': 'Signaler un bug',
|
||||||
|
'bugReport.intro': "Vous avez trouvé un problème ? Décrivez-le ci-dessous : le signalement arrive directement dans le panneau d'administration.",
|
||||||
|
'bugReport.englishNotice': "Rédigez votre signalement en anglais, quelle que soit la langue que vous utilisez pour naviguer : cela nous aide à le traiter plus rapidement.",
|
||||||
|
'bugReport.descriptionLabel': "Que s'est-il passé ?",
|
||||||
|
'bugReport.descriptionPlaceholder': "Décrivez le bug : ce que vous faisiez, ce que vous attendiez et ce qui s'est passé à la place.",
|
||||||
|
'bugReport.contactLabel': 'Contact (facultatif)',
|
||||||
|
'bugReport.contactPlaceholder': 'Email ou autre contact, si vous souhaitez une réponse',
|
||||||
|
'bugReport.submitBtn': 'Envoyer le signalement',
|
||||||
|
'bugReport.submitting': 'Envoi…',
|
||||||
|
'bugReport.blankError': "Décrivez le bug avant d'envoyer.",
|
||||||
|
'bugReport.successToast': 'Merci ! Signalement envoyé.',
|
||||||
|
'bugReport.errorPrefix': "Erreur lors de l'envoi : ",
|
||||||
|
'bugReport.myReportsTitle': 'Vos signalements',
|
||||||
|
'bugReport.myReportsHint': "Seulement les signalements envoyés depuis ce compte, avec le statut mis à jour par l'équipe d'administration.",
|
||||||
|
'bugReport.myReportsEmpty': "Vous n'avez encore envoyé aucun signalement.",
|
||||||
|
'bugReport.statusOpen': 'Non lu',
|
||||||
|
'bugReport.statusRead': 'Prise en charge',
|
||||||
|
'bugReport.statusResolved': 'Résolu',
|
||||||
|
'bugReport.backLink': "Retour à l'accueil",
|
||||||
'nav.logoutTitle': 'Se déconnecter',
|
'nav.logoutTitle': 'Se déconnecter',
|
||||||
'nav.logoutAria': 'Se déconnecter du compte',
|
'nav.logoutAria': 'Se déconnecter du compte',
|
||||||
'nav.deposit': 'Dépôt',
|
'nav.deposit': 'Dépôt',
|
||||||
@@ -573,6 +653,26 @@ const TRANSLATIONS = {
|
|||||||
'nav.guideTitle': 'Anleitung',
|
'nav.guideTitle': 'Anleitung',
|
||||||
'nav.guideAria': 'Benutzerhandbuch öffnen',
|
'nav.guideAria': 'Benutzerhandbuch öffnen',
|
||||||
'nav.bugReport': 'Fehler melden',
|
'nav.bugReport': 'Fehler melden',
|
||||||
|
'bugReport.pageTitle': 'Fehler melden',
|
||||||
|
'bugReport.heading': 'Fehler melden',
|
||||||
|
'bugReport.intro': 'Ein Problem gefunden? Beschreibe es unten — die Meldung geht direkt an das Admin-Panel.',
|
||||||
|
'bugReport.englishNotice': 'Bitte schreibe die Fehlermeldung auf Englisch, unabhängig von der Sprache, die du gerade verwendest — das hilft uns, sie schneller zu bearbeiten.',
|
||||||
|
'bugReport.descriptionLabel': 'Was ist passiert?',
|
||||||
|
'bugReport.descriptionPlaceholder': 'Beschreibe den Fehler: was du getan hast, was du erwartet hast und was stattdessen passiert ist.',
|
||||||
|
'bugReport.contactLabel': 'Kontakt (optional)',
|
||||||
|
'bugReport.contactPlaceholder': 'E-Mail oder anderer Kontakt, falls du eine Antwort möchtest',
|
||||||
|
'bugReport.submitBtn': 'Meldung senden',
|
||||||
|
'bugReport.submitting': 'Senden…',
|
||||||
|
'bugReport.blankError': 'Beschreibe den Fehler, bevor du sendest.',
|
||||||
|
'bugReport.successToast': 'Danke! Meldung gesendet.',
|
||||||
|
'bugReport.errorPrefix': 'Fehler beim Senden: ',
|
||||||
|
'bugReport.myReportsTitle': 'Deine Meldungen',
|
||||||
|
'bugReport.myReportsHint': 'Nur Meldungen, die von diesem Konto gesendet wurden, mit dem vom Admin-Team aktualisierten Status.',
|
||||||
|
'bugReport.myReportsEmpty': 'Du hast noch keine Meldungen gesendet.',
|
||||||
|
'bugReport.statusOpen': 'Ungelesen',
|
||||||
|
'bugReport.statusRead': 'In Bearbeitung',
|
||||||
|
'bugReport.statusResolved': 'Gelöst',
|
||||||
|
'bugReport.backLink': 'Zurück zur Startseite',
|
||||||
'nav.logoutTitle': 'Abmelden',
|
'nav.logoutTitle': 'Abmelden',
|
||||||
'nav.logoutAria': 'Vom Konto abmelden',
|
'nav.logoutAria': 'Vom Konto abmelden',
|
||||||
'nav.deposit': 'Einzahlung',
|
'nav.deposit': 'Einzahlung',
|
||||||
@@ -713,6 +813,26 @@ const TRANSLATIONS = {
|
|||||||
'nav.guideTitle': 'Инструкция',
|
'nav.guideTitle': 'Инструкция',
|
||||||
'nav.guideAria': 'Открыть руководство пользователя',
|
'nav.guideAria': 'Открыть руководство пользователя',
|
||||||
'nav.bugReport': 'Сообщить об ошибке',
|
'nav.bugReport': 'Сообщить об ошибке',
|
||||||
|
'bugReport.pageTitle': 'Сообщить об ошибке',
|
||||||
|
'bugReport.heading': 'Сообщить об ошибке',
|
||||||
|
'bugReport.intro': 'Нашли проблему? Опишите её ниже — сообщение сразу попадёт в панель администратора.',
|
||||||
|
'bugReport.englishNotice': 'Пожалуйста, опишите ошибку на английском языке, независимо от языка интерфейса — это поможет нам обработать её быстрее.',
|
||||||
|
'bugReport.descriptionLabel': 'Что произошло?',
|
||||||
|
'bugReport.descriptionPlaceholder': 'Опишите ошибку: что вы делали, что ожидали и что произошло вместо этого.',
|
||||||
|
'bugReport.contactLabel': 'Контакт (необязательно)',
|
||||||
|
'bugReport.contactPlaceholder': 'Email или другой контакт, если хотите получить ответ',
|
||||||
|
'bugReport.submitBtn': 'Отправить сообщение',
|
||||||
|
'bugReport.submitting': 'Отправка…',
|
||||||
|
'bugReport.blankError': 'Опишите ошибку перед отправкой.',
|
||||||
|
'bugReport.successToast': 'Спасибо! Сообщение отправлено.',
|
||||||
|
'bugReport.errorPrefix': 'Ошибка отправки: ',
|
||||||
|
'bugReport.myReportsTitle': 'Ваши сообщения',
|
||||||
|
'bugReport.myReportsHint': 'Только сообщения, отправленные с этого аккаунта, со статусом, обновлённым администрацией.',
|
||||||
|
'bugReport.myReportsEmpty': 'Вы ещё не отправляли сообщений.',
|
||||||
|
'bugReport.statusOpen': 'Не прочитано',
|
||||||
|
'bugReport.statusRead': 'В обработке',
|
||||||
|
'bugReport.statusResolved': 'Решено',
|
||||||
|
'bugReport.backLink': 'Назад на главную',
|
||||||
'nav.logoutTitle': 'Выйти',
|
'nav.logoutTitle': 'Выйти',
|
||||||
'nav.logoutAria': 'Выйти из аккаунта',
|
'nav.logoutAria': 'Выйти из аккаунта',
|
||||||
'nav.deposit': 'Депозит',
|
'nav.deposit': 'Депозит',
|
||||||
@@ -853,6 +973,26 @@ const TRANSLATIONS = {
|
|||||||
'nav.guideTitle': '指南',
|
'nav.guideTitle': '指南',
|
||||||
'nav.guideAria': '打开用户指南',
|
'nav.guideAria': '打开用户指南',
|
||||||
'nav.bugReport': '报告问题',
|
'nav.bugReport': '报告问题',
|
||||||
|
'bugReport.pageTitle': '报告问题',
|
||||||
|
'bugReport.heading': '报告问题',
|
||||||
|
'bugReport.intro': '发现问题了吗?请在下面描述——您的反馈会直接发送到管理员面板。',
|
||||||
|
'bugReport.englishNotice': '请用英文描述问题,无论您当前使用的是哪种语言界面——这有助于我们更快处理。',
|
||||||
|
'bugReport.descriptionLabel': '发生了什么?',
|
||||||
|
'bugReport.descriptionPlaceholder': '描述问题:您当时在做什么、期望的结果是什么,以及实际发生了什么。',
|
||||||
|
'bugReport.contactLabel': '联系方式(可选)',
|
||||||
|
'bugReport.contactPlaceholder': '如果希望得到回复,请留下邮箱或其他联系方式',
|
||||||
|
'bugReport.submitBtn': '发送反馈',
|
||||||
|
'bugReport.submitting': '发送中…',
|
||||||
|
'bugReport.blankError': '请先描述问题再发送。',
|
||||||
|
'bugReport.successToast': '谢谢!反馈已发送。',
|
||||||
|
'bugReport.errorPrefix': '发送出错:',
|
||||||
|
'bugReport.myReportsTitle': '您的反馈',
|
||||||
|
'bugReport.myReportsHint': '仅显示此账户发送的反馈,状态由管理团队更新。',
|
||||||
|
'bugReport.myReportsEmpty': '您还没有发送过反馈。',
|
||||||
|
'bugReport.statusOpen': '待处理',
|
||||||
|
'bugReport.statusRead': '处理中',
|
||||||
|
'bugReport.statusResolved': '已解决',
|
||||||
|
'bugReport.backLink': '返回首页',
|
||||||
'nav.logoutTitle': '退出登录',
|
'nav.logoutTitle': '退出登录',
|
||||||
'nav.logoutAria': '退出账户',
|
'nav.logoutAria': '退出账户',
|
||||||
'nav.deposit': '存款',
|
'nav.deposit': '存款',
|
||||||
|
|||||||
+159
-6
@@ -1,16 +1,169 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="it">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Segnala un bug — PLM Lottery</title>
|
<title data-i18n="bugReport.pageTitle">Report a bug</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/logo.svg">
|
||||||
<link rel="stylesheet" href="/style.css">
|
<link rel="stylesheet" href="/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app-shell">
|
<div class="app-shell app-shell-bugreport">
|
||||||
<h1>Segnala un bug</h1>
|
|
||||||
<p>Questa pagina è un placeholder. Il modulo per la segnalazione dei bug sarà disponibile qui a breve.</p>
|
<div class="bugreport-topbar">
|
||||||
<p><a class="link" href="/">← Torna alla home</a></p>
|
<a class="brand" href="/">
|
||||||
|
<img class="brand-mark" src="/logo.svg" alt="">
|
||||||
|
PLM Lottery
|
||||||
|
</a>
|
||||||
|
<div class="bugreport-topbar-right">
|
||||||
|
<a class="back-home-btn" href="/">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
||||||
|
<span data-i18n="bugReport.backLink">Back to home</span>
|
||||||
|
</a>
|
||||||
|
<select id="lang-switcher" class="lang-switcher" onchange="setLanguage(this.value)" aria-label="Language">
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="it">Italiano</option>
|
||||||
|
<option value="es">Español</option>
|
||||||
|
<option value="fr">Français</option>
|
||||||
|
<option value="de">Deutsch</option>
|
||||||
|
<option value="ru">Русский</option>
|
||||||
|
<option value="zh">中文</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bugreport-hero">
|
||||||
|
<div class="bugreport-hero-icon">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v5"/><path d="M12 16h.01"/></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 data-i18n="bugReport.heading">Report a bug</h1>
|
||||||
|
<p data-i18n="bugReport.intro">Found a problem? Describe it below — your report goes straight to the admin panel.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" id="report-form">
|
||||||
|
<div class="field-note" id="english-notice">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
||||||
|
<span data-i18n="bugReport.englishNotice">Please write your bug report in English, regardless of the language you're browsing in — this helps us handle it faster.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label for="bug-description" data-i18n="bugReport.descriptionLabel">What happened?</label>
|
||||||
|
<textarea id="bug-description" rows="6" maxlength="2000" data-i18n-placeholder="bugReport.descriptionPlaceholder" oninput="updateCharCount()"></textarea>
|
||||||
|
<div class="char-count" id="char-count">0 / 2000</div>
|
||||||
|
|
||||||
|
<label for="bug-contact" data-i18n="bugReport.contactLabel">Contact (optional)</label>
|
||||||
|
<input id="bug-contact" type="text" maxlength="256" data-i18n-placeholder="bugReport.contactPlaceholder">
|
||||||
|
|
||||||
|
<button onclick="submitBugReport()" id="bug-submit-btn" data-i18n="bugReport.submitBtn">Send report</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hidden" id="my-reports-section">
|
||||||
|
<p class="section-label" data-i18n="bugReport.myReportsTitle">Your reports</p>
|
||||||
|
<div class="card">
|
||||||
|
<p class="hint" data-i18n="bugReport.myReportsHint">Only reports sent from this account, with the status set by the admin team.</p>
|
||||||
|
<div id="my-reports-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="toast-container" aria-live="polite"></div>
|
||||||
|
|
||||||
|
<script src="/i18n.js"></script>
|
||||||
|
<script>
|
||||||
|
function toast(message, type) {
|
||||||
|
const container = document.getElementById('toast-container');
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'toast ' + type;
|
||||||
|
el.textContent = message;
|
||||||
|
container.appendChild(el);
|
||||||
|
setTimeout(() => el.remove(), 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCharCount() {
|
||||||
|
const field = document.getElementById('bug-description');
|
||||||
|
document.getElementById('char-count').textContent = field.value.length + ' / ' + field.maxLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitBugReport() {
|
||||||
|
const btn = document.getElementById('bug-submit-btn');
|
||||||
|
const description = document.getElementById('bug-description').value.trim();
|
||||||
|
const contact = document.getElementById('bug-contact').value.trim();
|
||||||
|
if (!description) {
|
||||||
|
toast(t('bugReport.blankError'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
const token = localStorage.getItem('plm_token');
|
||||||
|
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
const original = btn.textContent;
|
||||||
|
btn.textContent = t('bugReport.submitting');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/bug-reports', {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ description, contact: contact || null }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.detail?.message || data.detail || res.statusText);
|
||||||
|
document.getElementById('bug-description').value = '';
|
||||||
|
document.getElementById('bug-contact').value = '';
|
||||||
|
updateCharCount();
|
||||||
|
toast(t('bugReport.successToast'), 'success');
|
||||||
|
loadMyBugReports();
|
||||||
|
} catch (e) {
|
||||||
|
toast(t('bugReport.errorPrefix') + e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = original;
|
||||||
|
applyStaticTranslations(btn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMyBugReports() {
|
||||||
|
const token = localStorage.getItem('plm_token');
|
||||||
|
const section = document.getElementById('my-reports-section');
|
||||||
|
if (!token) {
|
||||||
|
section.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch('/bug-reports/mine', { headers: { Authorization: 'Bearer ' + token } });
|
||||||
|
if (!res.ok) {
|
||||||
|
section.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reports = await res.json();
|
||||||
|
section.classList.remove('hidden');
|
||||||
|
const list = document.getElementById('my-reports-list');
|
||||||
|
list.innerHTML = reports.map((r) => `
|
||||||
|
<div class="report-row">
|
||||||
|
<span class="badge bug-status-${escapeHtml(r.status)}">${escapeHtml(t('bugReport.status' + r.status.charAt(0).toUpperCase() + r.status.slice(1)))}</span>
|
||||||
|
<span class="report-row-desc" title="${escapeHtml(r.description)}">${escapeHtml(r.description)}</span>
|
||||||
|
<span class="report-row-date">${new Date(r.created_at).toLocaleDateString(currentDateLocale(), { day: 'numeric', month: 'short', year: 'numeric' })}</span>
|
||||||
|
</div>
|
||||||
|
`).join('') || `<p class="hint report-empty">${escapeHtml(t('bugReport.myReportsEmpty'))}</p>`;
|
||||||
|
} catch (e) {
|
||||||
|
section.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-renders server-rendered content (my reports list) on a language switch,
|
||||||
|
// the same split app.js uses between data-i18n (static markup) and t() (data).
|
||||||
|
function onLanguageChange() {
|
||||||
|
loadMyBugReports();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCharCount();
|
||||||
|
loadMyBugReports();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+91
-3
@@ -74,16 +74,17 @@ h1, h2, h3 { font-family: inherit; letter-spacing: -0.01em; }
|
|||||||
label { display: block; font-size: 0.85rem; font-weight: 500; color: var(--color-muted-foreground); margin-top: 14px; margin-bottom: 6px; }
|
label { display: block; font-size: 0.85rem; font-weight: 500; color: var(--color-muted-foreground); margin-top: 14px; margin-bottom: 6px; }
|
||||||
label:first-child { margin-top: 0; }
|
label:first-child { margin-top: 0; }
|
||||||
|
|
||||||
input {
|
input, textarea {
|
||||||
width: 100%; min-height: 44px; padding: 10px 12px; font-size: 0.95rem; font-family: inherit;
|
width: 100%; min-height: 44px; padding: 10px 12px; font-size: 0.95rem; font-family: inherit;
|
||||||
border: 1px solid var(--color-border); border-radius: var(--radius-sm); background: var(--color-surface);
|
border: 1px solid var(--color-border); border-radius: var(--radius-sm); background: var(--color-surface);
|
||||||
color: var(--color-foreground); transition: border-color 150ms, box-shadow 150ms;
|
color: var(--color-foreground); transition: border-color 150ms, box-shadow 150ms;
|
||||||
}
|
}
|
||||||
input:focus {
|
textarea { resize: vertical; }
|
||||||
|
input:focus, textarea:focus {
|
||||||
outline: none; border-color: var(--color-ring);
|
outline: none; border-color: var(--color-ring);
|
||||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-ring) 25%, transparent);
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-ring) 25%, transparent);
|
||||||
}
|
}
|
||||||
input:disabled { background: var(--color-surface-inset); color: var(--color-muted-foreground); }
|
input:disabled, textarea:disabled { background: var(--color-surface-inset); color: var(--color-muted-foreground); }
|
||||||
|
|
||||||
button {
|
button {
|
||||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||||
@@ -253,6 +254,93 @@ button.link:hover, a.link:hover { filter: none; color: var(--color-foreground);
|
|||||||
.toast.error { background: var(--color-destructive-bg); color: var(--color-destructive); }
|
.toast.error { background: var(--color-destructive-bg); color: var(--color-destructive); }
|
||||||
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
|
||||||
|
/* --- /report-bug: a standalone page (no logged-in navbar), so it gets its
|
||||||
|
own slim top bar rather than the app's bottom tab bar / sticky header. --- */
|
||||||
|
.app-shell-bugreport { padding-bottom: 32px; }
|
||||||
|
|
||||||
|
.bugreport-topbar {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||||
|
padding: 4px 0 20px; margin-bottom: 20px; border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.bugreport-topbar .brand {
|
||||||
|
display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 1rem;
|
||||||
|
letter-spacing: -0.01em; color: var(--color-foreground); text-decoration: none;
|
||||||
|
}
|
||||||
|
.bugreport-topbar .brand-mark { width: 26px; height: 26px; border-radius: 50%; flex-shrink: 0; display: block; }
|
||||||
|
.bugreport-topbar-right { display: flex; align-items: center; gap: 12px; }
|
||||||
|
|
||||||
|
/* Pill button, same idiom as .trust-pill / .chain-status-pill elsewhere on the
|
||||||
|
site: a bordered chip rather than a bare text link, so "go back" reads as an
|
||||||
|
actual control instead of fading into the surrounding copy. */
|
||||||
|
.back-home-btn {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
font-size: 0.8rem; font-weight: 500; color: var(--color-muted-foreground);
|
||||||
|
background: var(--color-surface); border: 1px solid var(--color-border);
|
||||||
|
padding: 6px 12px 6px 10px; border-radius: 999px; text-decoration: none;
|
||||||
|
transition: color 150ms, border-color 150ms, background 150ms;
|
||||||
|
}
|
||||||
|
.back-home-btn .icon { width: 15px; height: 15px; }
|
||||||
|
.back-home-btn:hover {
|
||||||
|
color: var(--color-foreground); background: var(--color-surface-inset);
|
||||||
|
border-color: color-mix(in srgb, var(--color-ring) 40%, var(--color-border));
|
||||||
|
}
|
||||||
|
.back-home-btn:focus-visible { outline: 2px solid var(--color-ring); outline-offset: 2px; }
|
||||||
|
|
||||||
|
.bugreport-hero { display: flex; align-items: flex-start; gap: 14px; margin-bottom: 20px; }
|
||||||
|
.bugreport-hero-icon {
|
||||||
|
width: 44px; height: 44px; flex-shrink: 0; border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
|
||||||
|
color: var(--color-primary);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.bugreport-hero-icon .icon { width: 22px; height: 22px; }
|
||||||
|
.bugreport-hero h1 { font-size: 1.3rem; font-weight: 700; margin: 2px 0 4px; text-wrap: balance; }
|
||||||
|
.bugreport-hero p { color: var(--color-muted-foreground); font-size: 0.9rem; margin: 0; max-width: 46ch; }
|
||||||
|
|
||||||
|
/* Info callout, anchored inside the form card right above the field it
|
||||||
|
applies to — not a warning (that's what the amber status badges below are
|
||||||
|
for), so it gets the accent hue instead, keeping the two meanings visually
|
||||||
|
distinct. */
|
||||||
|
.field-note {
|
||||||
|
display: flex; align-items: flex-start; gap: 10px;
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 28%, transparent);
|
||||||
|
color: color-mix(in srgb, var(--color-accent) 75%, var(--color-foreground));
|
||||||
|
border-radius: var(--radius-sm); padding: 10px 12px; font-size: 0.82rem; line-height: 1.4;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.field-note .icon { width: 16px; height: 16px; margin-top: 1px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.char-count {
|
||||||
|
font-variant-numeric: tabular-nums; text-align: right;
|
||||||
|
font-size: 0.75rem; color: var(--color-muted-foreground); margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block; font-size: 0.72rem; font-weight: 600; padding: 2px 8px;
|
||||||
|
border-radius: 999px; background: var(--color-background); border: 1px solid var(--color-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.badge.bug-status-open { background: color-mix(in srgb, var(--color-primary) 16%, transparent); color: #92400E; border-color: color-mix(in srgb, var(--color-primary) 55%, transparent); }
|
||||||
|
.badge.bug-status-read { background: var(--color-background); color: var(--color-muted-foreground); }
|
||||||
|
.badge.bug-status-resolved { background: var(--color-success-bg); color: var(--color-success); border-color: var(--color-success); }
|
||||||
|
|
||||||
|
.report-row {
|
||||||
|
display: flex; flex-wrap: wrap; align-items: center; gap: 10px;
|
||||||
|
padding: 12px 0; border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.report-row:first-child { padding-top: 0; }
|
||||||
|
.report-row:last-child { padding-bottom: 0; border-bottom: none; }
|
||||||
|
.report-row-desc {
|
||||||
|
flex: 1 1 200px; font-size: 0.88rem;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.report-row-date {
|
||||||
|
font-size: 0.78rem; color: var(--color-muted-foreground); white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.report-empty { margin: 0; }
|
||||||
|
|
||||||
/* --- landing hero (shown only when logged out) --- */
|
/* --- landing hero (shown only when logged out) --- */
|
||||||
body {
|
body {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
+19
-1
@@ -38,6 +38,15 @@ async def compute_pending_balance(session: AsyncSession, user: User) -> tuple[in
|
|||||||
user's own address. Adding that to cached_balance_sats gives the balance the
|
user's own address. Adding that to cached_balance_sats gives the balance the
|
||||||
user will end up with once everything currently in flight confirms.
|
user will end up with once everything currently in flight confirms.
|
||||||
|
|
||||||
|
The change output's own confirmation is credited by two independent, unordered
|
||||||
|
paths: the Electrum listener (event-driven, near-instant — app/deposits/service.py
|
||||||
|
turns it into a UtxoEvent and folds it into cached_balance_sats via
|
||||||
|
recompute_balance) and this module's PendingTransaction.status flip
|
||||||
|
(app/tx/confirmation.py, polled every 10s). The listener usually wins that race,
|
||||||
|
so for the gap until the poller catches up the row is still "pending" here while
|
||||||
|
the same sats are already inside cached_balance_sats — double-counting the
|
||||||
|
change unless excluded below.
|
||||||
|
|
||||||
Returns (pending_inclusive_balance_sats, has_pending) — has_pending tells the
|
Returns (pending_inclusive_balance_sats, has_pending) — has_pending tells the
|
||||||
caller whether this differs from the confirmed-only balance at all.
|
caller whether this differs from the confirmed-only balance at all.
|
||||||
"""
|
"""
|
||||||
@@ -54,10 +63,19 @@ async def compute_pending_balance(session: AsyncSession, user: User) -> tuple[in
|
|||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
|
already_credited = {
|
||||||
|
(txid, vout)
|
||||||
|
for txid, vout in (
|
||||||
|
await session.execute(select(UtxoEvent.txid, UtxoEvent.vout).where(UtxoEvent.user_id == user.id))
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
|
||||||
pending_change_sats = 0
|
pending_change_sats = 0
|
||||||
for row in pending:
|
for row in pending:
|
||||||
tx = Transaction.parse(bytes.fromhex(row.raw_tx_hex))
|
tx = Transaction.parse(bytes.fromhex(row.raw_tx_hex))
|
||||||
for out in tx.vout:
|
for vout, out in enumerate(tx.vout):
|
||||||
|
if (row.current_txid, vout) in already_credited:
|
||||||
|
continue
|
||||||
if out.script_pubkey.address(network=PLM_MAINNET) == user.address:
|
if out.script_pubkey.address(network=PLM_MAINNET) == user.address:
|
||||||
pending_change_sats += out.value
|
pending_change_sats += out.value
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""replace bug_reports.resolved with a three-state status
|
||||||
|
|
||||||
|
Revision ID: be71fdac734e
|
||||||
|
Revises: ee8508d98d34
|
||||||
|
Create Date: 2026-07-31 15:33:51.780062
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'be71fdac734e'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = 'ee8508d98d34'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema, preserving existing rows: resolved=True -> 'resolved', else 'open'.
|
||||||
|
|
||||||
|
'read' has no equivalent in the old boolean, so nothing backfills into it —
|
||||||
|
every previously-open report starts the new lifecycle at 'open', which is
|
||||||
|
correct (nobody had acknowledged it yet)."""
|
||||||
|
op.add_column('bug_reports', sa.Column('status', sa.String(length=16), nullable=True))
|
||||||
|
op.execute("UPDATE bug_reports SET status = CASE WHEN resolved THEN 'resolved' ELSE 'open' END")
|
||||||
|
with op.batch_alter_table('bug_reports') as batch_op:
|
||||||
|
batch_op.alter_column('status', nullable=False)
|
||||||
|
batch_op.drop_column('resolved')
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema. 'read' collapses back into resolved=False — the same loss
|
||||||
|
of information any boolean-from-enum downgrade has."""
|
||||||
|
op.add_column('bug_reports', sa.Column('resolved', sa.BOOLEAN(), nullable=True))
|
||||||
|
op.execute("UPDATE bug_reports SET resolved = (status = 'resolved')")
|
||||||
|
with op.batch_alter_table('bug_reports') as batch_op:
|
||||||
|
batch_op.alter_column('resolved', nullable=False)
|
||||||
|
batch_op.drop_column('status')
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""add bug_reports table
|
||||||
|
|
||||||
|
Revision ID: ee8508d98d34
|
||||||
|
Revises: 87a0c640355c
|
||||||
|
Create Date: 2026-07-31 15:14:14.288552
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'ee8508d98d34'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '87a0c640355c'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('bug_reports',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=False),
|
||||||
|
sa.Column('contact', sa.String(length=256), nullable=True),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('resolved', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('bug_reports')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -91,6 +91,54 @@ async def test_pending_balance_matches_confirmed_when_nothing_in_flight(session_
|
|||||||
assert pending_balance == 2_000_000_000
|
assert pending_balance == 2_000_000_000
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pending_balance_does_not_double_count_change_already_credited(session_factory):
|
||||||
|
"""The Electrum listener (event-driven) and the confirmation poller (10s
|
||||||
|
cadence) independently react to the same change output confirming. When the
|
||||||
|
listener wins that race — the common case — the change is already a
|
||||||
|
UtxoEvent inside cached_balance_sats while the PendingTransaction row is
|
||||||
|
still "pending". compute_pending_balance must not add the change a second
|
||||||
|
time in that window."""
|
||||||
|
user_id = await _make_funded_user(session_factory, 4, 1_500_000_000)
|
||||||
|
client = FakeElectrumClient()
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
user = await session.get(User, user_id)
|
||||||
|
await place_bet(session, client, user)
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
pending = (await session.scalars(select(PendingTransaction))).one()
|
||||||
|
from embit.transaction import Transaction
|
||||||
|
|
||||||
|
from app.wallet.plm_network import PLM_MAINNET
|
||||||
|
|
||||||
|
tx = Transaction.parse(bytes.fromhex(pending.raw_tx_hex))
|
||||||
|
change_vout, change_out = next(
|
||||||
|
(i, out) for i, out in enumerate(tx.vout) if out.script_pubkey.address(network=PLM_MAINNET) == user.address
|
||||||
|
)
|
||||||
|
|
||||||
|
user = await session.get(User, user_id)
|
||||||
|
# Simulate the listener having already credited the change output as
|
||||||
|
# confirmed, before the poller has flipped `pending.status`.
|
||||||
|
session.add(
|
||||||
|
UtxoEvent(
|
||||||
|
user_id=user_id,
|
||||||
|
txid=pending.current_txid,
|
||||||
|
vout=change_vout,
|
||||||
|
amount_sats=change_out.value,
|
||||||
|
confirmed_height=101,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await recompute_balance(session, user_id)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
user = await session.get(User, user_id)
|
||||||
|
pending_balance, has_pending = await compute_pending_balance(session, user)
|
||||||
|
|
||||||
|
assert has_pending is True # the PendingTransaction row is still "pending"
|
||||||
|
assert pending_balance == user.cached_balance_sats # already-credited change isn't added again
|
||||||
|
|
||||||
|
|
||||||
async def test_pending_balance_ignores_other_users_pending_transactions(session_factory):
|
async def test_pending_balance_ignores_other_users_pending_transactions(session_factory):
|
||||||
user_id = await _make_funded_user(session_factory, 2, 2_000_000_000)
|
user_id = await _make_funded_user(session_factory, 2, 2_000_000_000)
|
||||||
other_user_id = await _make_funded_user(session_factory, 3, 1_500_000_000)
|
other_user_id = await _make_funded_user(session_factory, 3, 1_500_000_000)
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import pytest
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(settings, "database_url", f"sqlite+aiosqlite:///{tmp_path}/test.db")
|
||||||
|
monkeypatch.setattr(settings, "admin_token", "test-admin-token")
|
||||||
|
monkeypatch.setattr(settings, "jwt_secret", "test-jwt-secret")
|
||||||
|
monkeypatch.setattr(settings, "xprv_encryption_key", Fernet.generate_key().decode())
|
||||||
|
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc"))
|
||||||
|
|
||||||
|
import app.wallet.hd as hd
|
||||||
|
|
||||||
|
hd._account_key = None
|
||||||
|
hd.generate_master_key()
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from app.db import base as db_base
|
||||||
|
|
||||||
|
import app.db.models # noqa: F401
|
||||||
|
|
||||||
|
db_base.engine = create_async_engine(settings.database_url)
|
||||||
|
db_base.AsyncSessionLocal = async_sessionmaker(db_base.engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
from app.db import session as db_session
|
||||||
|
|
||||||
|
db_session.AsyncSessionLocal = db_base.AsyncSessionLocal
|
||||||
|
|
||||||
|
async with db_base.engine.begin() as conn:
|
||||||
|
await conn.run_sync(db_base.Base.metadata.create_all)
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from app.api.routes.admin import router as admin_router
|
||||||
|
from app.api.routes.bug_reports import router as bug_reports_router
|
||||||
|
from app.auth.routes import router as auth_router
|
||||||
|
from app.electrum.listener import ElectrumListener
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(auth_router)
|
||||||
|
app.include_router(bug_reports_router)
|
||||||
|
app.include_router(admin_router)
|
||||||
|
app.state.electrum_listener = ElectrumListener(lambda endpoint: None, db_base.AsyncSessionLocal)
|
||||||
|
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||||
|
yield ac
|
||||||
|
|
||||||
|
await db_base.engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
_ADMIN_HEADERS = {"X-Admin-Token": "test-admin-token"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _register(client, username="alice", password="original-password"):
|
||||||
|
resp = await client.post("/auth/register", json={"username": username, "password": password})
|
||||||
|
assert resp.status_code == 201
|
||||||
|
return resp.json()["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_anonymous_bug_report_has_no_user(client):
|
||||||
|
resp = await client.post("/bug-reports", json={"description": "the bet button does nothing"})
|
||||||
|
assert resp.status_code == 201
|
||||||
|
|
||||||
|
resp = await client.get("/admin/bug-reports", headers=_ADMIN_HEADERS)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
reports = resp.json()
|
||||||
|
assert len(reports) == 1
|
||||||
|
assert reports[0]["description"] == "the bet button does nothing"
|
||||||
|
assert reports[0]["user_id"] is None
|
||||||
|
assert reports[0]["username"] is None
|
||||||
|
assert reports[0]["status"] == "open"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_logged_in_bug_report_is_attributed_to_the_user(client):
|
||||||
|
token = await _register(client)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/bug-reports",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"description": "withdrawal amount looks wrong", "contact": "alice@example.com"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
|
||||||
|
resp = await client.get("/admin/bug-reports", headers=_ADMIN_HEADERS)
|
||||||
|
reports = resp.json()
|
||||||
|
assert reports[0]["username"] == "alice"
|
||||||
|
assert reports[0]["contact"] == "alice@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_user_can_see_own_report_status(client):
|
||||||
|
token = await _register(client)
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
resp = await client.post("/bug-reports", headers=headers, json={"description": "some bug"})
|
||||||
|
report_id = resp.json()["id"]
|
||||||
|
|
||||||
|
resp = await client.get("/bug-reports/mine", headers=headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
reports = resp.json()
|
||||||
|
assert len(reports) == 1
|
||||||
|
assert reports[0]["id"] == report_id
|
||||||
|
assert reports[0]["status"] == "open"
|
||||||
|
|
||||||
|
await client.post(
|
||||||
|
f"/admin/bug-reports/{report_id}/status", headers=_ADMIN_HEADERS, json={"status": "read"}
|
||||||
|
)
|
||||||
|
resp = await client.get("/bug-reports/mine", headers=headers)
|
||||||
|
assert resp.json()[0]["status"] == "read"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_bug_reports_mine_requires_auth(client):
|
||||||
|
resp = await client.get("/bug-reports/mine")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_bug_reports_mine_only_returns_own_reports(client):
|
||||||
|
alice_token = await _register(client, username="alice")
|
||||||
|
bob_token = await _register(client, username="bob", password="bob-password")
|
||||||
|
|
||||||
|
await client.post(
|
||||||
|
"/bug-reports", headers={"Authorization": f"Bearer {alice_token}"}, json={"description": "alice's bug"}
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.get("/bug-reports/mine", headers={"Authorization": f"Bearer {bob_token}"})
|
||||||
|
assert resp.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_blank_description_is_rejected(client):
|
||||||
|
resp = await client.post("/bug-reports", json={"description": " "})
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_bug_reports_requires_token(client):
|
||||||
|
resp = await client.get("/admin/bug-reports")
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_can_move_through_open_read_resolved(client):
|
||||||
|
resp = await client.post("/bug-reports", json={"description": "some bug"})
|
||||||
|
report_id = resp.json()["id"]
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"/admin/bug-reports/{report_id}/status", headers=_ADMIN_HEADERS, json={"status": "read"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["status"] == "read"
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"/admin/bug-reports/{report_id}/status", headers=_ADMIN_HEADERS, json={"status": "resolved"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["status"] == "resolved"
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"/admin/bug-reports/{report_id}/status", headers=_ADMIN_HEADERS, json={"status": "open"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["status"] == "open"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_status_rejects_unknown_value(client):
|
||||||
|
resp = await client.post("/bug-reports", json={"description": "some bug"})
|
||||||
|
report_id = resp.json()["id"]
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"/admin/bug-reports/{report_id}/status", headers=_ADMIN_HEADERS, json={"status": "bogus"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_status_unknown_report_is_404(client):
|
||||||
|
resp = await client.post(
|
||||||
|
"/admin/bug-reports/999/status", headers=_ADMIN_HEADERS, json={"status": "read"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
Reference in New Issue
Block a user