diff --git a/CLAUDE.md b/CLAUDE.md index 2678efc..aca69b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,7 @@ Known risk: `docker-compose.yml` sets `restart: unless-stopped` on `app`, so a c - **Secrets**: master xprv encrypted at rest with a symmetric scheme (AES-GCM/Fernet); the encryption key itself lives in an env var, never in the DB or in git. - **Operational config**: every business/round parameter (fee address, bet amount, round duration, round cooldown, draw animation duration, minimum amount, network fee rate, RBF timeout) lives in the `round_config` DB table (single row, `app/rounds/config.py`) and is only editable live via the admin dashboard (`/admin`) or its API — no env var involved at all, no redeploy or restart needed. Defaults for a brand-new instance are hardcoded column defaults on the `RoundConfig` model (`app/db/models.py`), not `app/config.py`. Secrets and infra wiring (master key, JWT secret, Electrum host, admin token, database URL) stay env-var-driven in `.env` since those genuinely need a restart. - **Round cooldown**: `round_cooldown_seconds` — gap after a round closes before the next one opens, so players have time to see the outcome (default 30s). Not in the original flowchart; added afterwards as an explicit design decision. +- **Maintenance pause**: `RoundConfig.paused` (default `false`), toggled via `POST /admin/pause` / `POST /admin/resume` (a dedicated "Manutenzione" card in `/admin`'s Parametri section, not a plain config field — it's a deliberate operator action, audit-logged as `lottery_paused`/`lottery_resumed`). When set, `rounds/service.py:open_new_round_if_needed` stops opening a *next* round once the current one closes — it never interrupts a round already in progress (that one still closes, draws, and pays out its winner normally). `GET /rounds/current` exposes it as `lottery_paused` so the user-facing page (`/`) shows a maintenance banner. ## PLM network parameters diff --git a/app/api/routes/admin.py b/app/api/routes/admin.py index 9e7af95..d301ed1 100644 --- a/app/api/routes/admin.py +++ b/app/api/routes/admin.py @@ -29,6 +29,7 @@ _CONFIG_FIELDS = ( "fee_rate_sat_vb", "rbf_timeout_seconds", "draw_animation_seconds", + "paused", ) @@ -41,6 +42,7 @@ class RoundConfigResponse(BaseModel): fee_rate_sat_vb: int rbf_timeout_seconds: int draw_animation_seconds: int + paused: bool class RoundConfigUpdate(BaseModel): @@ -52,6 +54,7 @@ class RoundConfigUpdate(BaseModel): fee_rate_sat_vb: int | None = None rbf_timeout_seconds: int | None = None draw_animation_seconds: int | None = None + paused: bool | None = None def _config_response(config) -> RoundConfigResponse: @@ -78,6 +81,27 @@ async def update_config( return _config_response(config) +@router.post("/pause", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)]) +async def pause_lottery(session: AsyncSession = Depends(get_session)) -> RoundConfigResponse: + """Maintenance switch: the round in progress (if any) still closes, draws, + and pays out its winner normally — only opening the *next* round is + suppressed until /admin/resume is called (rounds/service.py).""" + config = await get_round_config(session) + config.paused = True + await write_audit_log(session, "lottery_paused", {}) + await session.commit() + return _config_response(config) + + +@router.post("/resume", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)]) +async def resume_lottery(session: AsyncSession = Depends(get_session)) -> RoundConfigResponse: + config = await get_round_config(session) + config.paused = False + await write_audit_log(session, "lottery_resumed", {}) + await session.commit() + return _config_response(config) + + class AdminUserResponse(BaseModel): id: int username: str diff --git a/app/api/routes/rounds.py b/app/api/routes/rounds.py index 2b55a54..b85a9d0 100644 --- a/app/api/routes/rounds.py +++ b/app/api/routes/rounds.py @@ -25,6 +25,7 @@ class CurrentRoundResponse(BaseModel): winner_user_id: int | None = None winner_amount_sats: int | None = None chain_tip_height: int | None = None + lottery_paused: bool = False @router.get("/current", response_model=CurrentRoundResponse) @@ -39,6 +40,7 @@ async def current_round(request: Request, session: AsyncSession = Depends(get_se bet_amount_sats=config.bet_amount_sats, draw_animation_seconds=config.draw_animation_seconds, chain_tip_height=chain_tip_height, + lottery_paused=config.paused, ) participant_count = await session.scalar( @@ -60,4 +62,5 @@ async def current_round(request: Request, session: AsyncSession = Depends(get_se winner_user_id=round_.winner_user_id, winner_amount_sats=round_.winner_amount_sats, chain_tip_height=chain_tip_height, + lottery_paused=config.paused, ) diff --git a/app/db/models.py b/app/db/models.py index 9bf6dec..60927dc 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -95,6 +95,10 @@ class RoundConfig(Base): min_amount_sats: Mapped[int] = mapped_column(BigInteger, default=100_000_000) fee_rate_sat_vb: Mapped[int] = mapped_column(default=1) rbf_timeout_seconds: Mapped[int] = mapped_column(default=900) + # Maintenance switch: when true, the round currently in progress still runs to + # completion (closes, draws, pays out the winner) but no new round is opened + # afterwards — see rounds/service.py:open_new_round_if_needed. + paused: Mapped[bool] = mapped_column(default=False) updated_at: Mapped[datetime] = mapped_column(default=utcnow, onupdate=utcnow) diff --git a/app/rounds/service.py b/app/rounds/service.py index dcdb217..44b3eab 100644 --- a/app/rounds/service.py +++ b/app/rounds/service.py @@ -19,19 +19,26 @@ async def get_active_round(session: AsyncSession) -> Round | None: async def open_new_round_if_needed(session: AsyncSession) -> Round | None: """Returns the active round if one exists (whatever its status). Otherwise opens a fresh one, unless the last closed round's cooldown (ROUND_COOLDOWN_SECONDS) - hasn't elapsed yet — in which case returns None. Callers that need to attach a - bet must additionally check the returned round's status == "open" — a round in - closing/drawing/paying_out isn't accepting new bets, but a new round can't open - until it's done.""" + hasn't elapsed yet, or the lottery is paused for maintenance — in either case + returns None. Callers that need to attach a bet must additionally check the + returned round's status == "open" — a round in closing/drawing/paying_out + isn't accepting new bets, but a new round can't open until it's done. + + Pausing never touches a round already in progress: it only suppresses opening + the *next* one, so the current round still closes, draws, and pays out the + winner normally (see admin.py's /admin/pause and /admin/resume).""" active = await get_active_round(session) if active is not None: return active + config = await get_round_config(session) + if config.paused: + return None + last_closed = await session.scalar(select(Round).where(Round.status == "closed").order_by(Round.id.desc())) if last_closed is not None and last_closed.closed_at is not None: closed_at = last_closed.closed_at.replace(tzinfo=timezone.utc) - cooldown_seconds = (await get_round_config(session)).round_cooldown_seconds - if datetime.now(timezone.utc) < closed_at + timedelta(seconds=cooldown_seconds): + if datetime.now(timezone.utc) < closed_at + timedelta(seconds=config.round_cooldown_seconds): return None round_ = Round(status="open") diff --git a/app/static/admin.html b/app/static/admin.html index c101cb5..458032c 100644 --- a/app/static/admin.html +++ b/app/static/admin.html @@ -77,6 +77,12 @@ } .chain-block { color: var(--color-muted-foreground); font-size: 0.82rem; white-space: nowrap; } + .row-between { display: flex; align-items: center; justify-content: space-between; gap: 12px; } + #maintenance-btn.btn-stop { + background: var(--color-destructive-bg); color: var(--color-destructive); border-color: var(--color-destructive); + } + .status-dot.status-paused { background: var(--color-destructive); } + main { max-width: 960px; margin: 0 auto; padding: 24px 20px 80px; } .view { display: none; } @@ -221,6 +227,21 @@

Parametri

Configurazione operativa, salvata nel database — modificabile in qualsiasi momento senza riavviare il server.

+
+

Manutenzione

+

+ Interrompe l'apertura di nuovi round dopo quello in corso, senza troncare il round attuale — chiusura, + estrazione e pagamento del vincitore avvengono normalmente. Gli utenti vedono un avviso di manutenzione. +

+
+ + + + + +
+
+
@@ -460,11 +481,53 @@ async function adminLoadConfig() { document.getElementById('admin-min-amount').value = data.min_amount_sats / SATS_PER_PLM; document.getElementById('admin-fee-rate').value = data.fee_rate_sat_vb; document.getElementById('admin-rbf-timeout').value = data.rbf_timeout_seconds; + renderMaintenanceState(data.paused); } catch (e) { toast('Errore nel caricamento configurazione: ' + e.message, 'error'); } } +function renderMaintenanceState(paused) { + const dot = document.getElementById('maintenance-dot'); + const label = document.getElementById('maintenance-status-label'); + const btn = document.getElementById('maintenance-btn'); + btn.dataset.paused = paused ? '1' : '0'; + if (paused) { + dot.className = 'status-dot status-paused'; + label.textContent = 'In pausa: nessun nuovo round verrà aperto'; + btn.textContent = 'Riprendi lotteria'; + btn.classList.remove('btn-stop'); + } else { + dot.className = 'status-dot status-open'; + label.textContent = 'Attiva: i round si susseguono normalmente'; + btn.textContent = 'Interrompi dopo questo round'; + btn.classList.add('btn-stop'); + } +} + +async function toggleMaintenance() { + const btn = document.getElementById('maintenance-btn'); + const isPaused = btn.dataset.paused === '1'; + const path = isPaused ? '/admin/resume' : '/admin/pause'; + if (!isPaused && !window.confirm( + "Nessun nuovo round verrà aperto dopo quello in corso, fino a quando non riprendi la lotteria. " + + "Il round attuale (se presente) verrà comunque completato e il vincitore pagato. Continuare?" + )) { + return; + } + btn.disabled = true; + try { + const data = await callAdmin('POST', path, {}); + renderMaintenanceState(data.paused); + toast(data.paused ? 'Lotteria in pausa.' : 'Lotteria ripresa.', 'success'); + refreshChainStatus(); + } catch (e) { + toast('Errore: ' + e.message, 'error'); + } finally { + btn.disabled = false; + } +} + async function adminSave() { const btn = document.getElementById('save-btn'); const feeAddress = document.getElementById('admin-fee-address').value; diff --git a/app/static/index.html b/app/static/index.html index f1dd84c..12d71d9 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -30,17 +30,12 @@ font-family: 'Fira Sans', system-ui, sans-serif; background: var(--color-background); color: var(--color-foreground); - max-width: 480px; - margin: 0 auto; - padding: 32px 20px 80px; line-height: 1.5; } - .mono { font-family: 'Fira Code', monospace; } + .app-shell { max-width: 480px; margin: 0 auto; padding: 24px 20px 80px; } - header { margin-bottom: 24px; } - header h1 { font-size: 1.375rem; font-weight: 700; margin: 0; letter-spacing: -0.01em; } - header p { color: var(--color-muted-foreground); font-size: 0.9rem; margin: 4px 0 0; } + .mono { font-family: 'Fira Code', monospace; } .card { background: var(--color-surface); @@ -102,11 +97,37 @@ } button.link:hover { filter: none; color: var(--color-foreground); } - .account-bar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; } - .account-bar .name { font-weight: 600; } - .row-between { display: flex; align-items: center; justify-content: space-between; gap: 12px; } + /* --- sticky app navbar: identity row + section tabs, shown only when logged in --- */ + .app-navbar { + position: sticky; top: 0; z-index: 20; + background: color-mix(in srgb, var(--color-surface) 92%, transparent); + backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); + border-bottom: 1px solid var(--color-border); margin-bottom: 20px; + } + .app-navbar-top { + max-width: 480px; margin: 0 auto; padding: 12px 20px 8px; + display: flex; align-items: center; justify-content: space-between; gap: 12px; + } + .app-navbar-top .brand { font-weight: 700; font-size: 1rem; letter-spacing: -0.01em; } + .app-navbar-account { display: flex; align-items: center; gap: 10px; } + .app-navbar-account .name { font-weight: 600; font-size: 0.85rem; } + + .app-navbar-tabs { max-width: 480px; margin: 0 auto; padding: 0 12px; display: flex; } + .app-navbar-tabs button.navbar-tab { + flex: 1; width: auto; min-height: auto; margin-top: 0; padding: 8px 4px 10px; + display: flex; flex-direction: column; align-items: center; gap: 3px; + font-size: 0.72rem; font-weight: 600; font-family: inherit; cursor: pointer; + background: none; color: var(--color-muted-foreground); + border: none; border-bottom: 2px solid transparent; margin-bottom: -1px; + transition: color 150ms, border-color 150ms; + } + .app-navbar-tabs button.navbar-tab .icon { width: 18px; height: 18px; } + .app-navbar-tabs button.navbar-tab.active { color: var(--color-primary); border-bottom-color: var(--color-primary); } + .app-navbar-tabs button.navbar-tab:hover { filter: none; color: var(--color-foreground); } + .app-navbar-tabs button.navbar-tab.active:hover { color: var(--color-primary); } + .address-box { display: flex; align-items: center; justify-content: space-between; gap: 8px; background: var(--color-background); border: 1px solid var(--color-border); @@ -118,20 +139,6 @@ .icon { width: 16px; height: 16px; flex-shrink: 0; } - nav.menu { display: flex; gap: 4px; margin-bottom: 16px; } - nav.menu button.nav-item { - flex: 1; width: auto; margin-top: 0; min-height: 56px; padding: 8px 4px; - flex-direction: column; gap: 4px; font-size: 0.8rem; font-weight: 600; - background: var(--color-surface); color: var(--color-muted-foreground); - border: 1px solid var(--color-border); border-radius: 10px; - } - nav.menu button.nav-item .icon { width: 20px; height: 20px; } - nav.menu button.nav-item.active { - background: var(--color-primary); color: var(--color-on-primary); border-color: var(--color-primary); - } - nav.menu button.nav-item:hover { filter: none; border-color: var(--color-ring); } - nav.menu button.nav-item.active:hover { filter: brightness(0.94); } - .dash-panel { display: none; } .dash-panel.active { display: block; } @@ -220,25 +227,6 @@ } .trust-pill .icon { width: 13px; height: 13px; color: var(--color-success); flex-shrink: 0; } - /* --- bento nav for the dashboard menu --- */ - nav.menu.bento { - display: grid; grid-template-columns: 1fr 1fr; grid-template-areas: "deposit deposit" "bet withdraw"; - gap: 8px; - } - nav.menu.bento button.nav-item { - width: 100%; align-items: flex-start; text-align: left; flex-direction: row; gap: 10px; - min-height: 64px; padding: 12px 14px; border-radius: 14px; - } - nav.menu.bento button#nav-deposit { grid-area: deposit; } - nav.menu.bento button#nav-bet { grid-area: bet; } - nav.menu.bento button#nav-withdraw { grid-area: withdraw; } - nav.menu.bento button.nav-item .icon { width: 20px; height: 20px; margin-top: 2px; } - nav.menu.bento button.nav-item .nav-item-text { display: flex; flex-direction: column; gap: 2px; } - nav.menu.bento button.nav-item .nav-item-title { font-size: 0.85rem; } - nav.menu.bento button.nav-item .nav-item-hint { - font-size: 0.7rem; font-weight: 400; color: inherit; opacity: 0.75; - } - /* --- glowing card while a round is drawing --- */ .card.drawing-glow { border-color: color-mix(in srgb, var(--color-primary) 55%, var(--color-border)); @@ -284,6 +272,14 @@ } .chain-block { color: var(--color-muted-foreground); white-space: nowrap; } + .maintenance-banner { + display: flex; align-items: flex-start; gap: 8px; + background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface)); + border: 1px solid color-mix(in srgb, var(--color-primary) 40%, transparent); + color: var(--color-foreground); border-radius: var(--radius); + padding: 12px 14px; font-size: 0.82rem; line-height: 1.4; margin-bottom: 16px; + } + @media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } } @@ -291,6 +287,32 @@ + + +
+
@@ -299,6 +321,11 @@ Blocco —
+ +

PLM Lottery

Deposita PLM, entra nel round con una quota fissa, e se viene estratto il tuo numero vinci il montepremi.

@@ -355,13 +382,6 @@ +
+