Compare commits

...
Author SHA1 Message Date
eminuxandCursor b87a0f7bc0 Fix race create sessione su CPX: ready solo dopo MediaMTX.
I nodi cloud restano in provisioning finché :9997 risponde; retry su create_path e 503 retryable se l’ingest è ancora irraggiungibile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 14:06:47 +02:00
eminuxandCursor 70fccc493e Usa screenshot di pagina come sfondo heatmap al posto dell’iframe.
Cattura periodica client-side (con consenso), storage ActiveStorage e overlay allineato all’immagine.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 23:14:47 +02:00
eminuxandCursor 406a90ae5b Bumpe site-analytics.js a v=3 per bypassare la cache NPM su collaudo.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 23:08:10 +02:00
eminuxandCursor 8e7bbdf8b2 Bumpe cache-bust della heatmap JS su collaudo (evita 404 NPM su ?v=1).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 23:07:07 +02:00
eminuxandCursor b84321346f Upgrade heatmaps stile Hotjar: movimenti mouse e anteprima pagina live.
Traccia i movimenti aggregati, overlay a gradienti sull’iframe della pagina e toggle click/move in admin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 23:03:12 +02:00
eminuxandCursor 8a07c5d569 Aggiunge heatmaps first-party (click/scroll) con pagina admin Analytics.
Raccoglie eventi aggregati dietro consenso cookie, senza PII né tracking su admin/regia/live/replay; GA4 resta invariato.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 22:50:15 +02:00
eminuxandCursor cc96c0396a Migliora sessioni admin e rende robusto il logout web.
Aggiunge filtri/colonne (società, orari) e dettaglio leggibile; evita 422 CSRF su logout e non cancella le cover slate custom in sync prod.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 20:41:12 +02:00
eminuxandCursor 79c48b226e Copia AAB/APK di release in native/android/dist con nome versionato.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:53:53 +02:00
eminuxandCursor c9b75065bc Bump Android a 2.0.12-native (versionCode 33) per Play Store.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:50:20 +02:00
eminuxandCursor 4ca9f3af8b Merge branch 'collaudo': avvisi multilanguage e allineamento sessioni admin.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:45:46 +02:00
eminuxandCursor f02782d0f0 Aggiunge traduzioni it/en/fr/de/es agli avvisi e toglie gli override Android/iOS.
Un avviso ha un testo per lingua; per differenziare le piattaforme si creano due avvisi. L'app riceve già il copy risolto da Accept-Language.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:26:30 +02:00
eminuxandCursor fb82b74e92 Allinea verticalmente le righe della tabella sessioni admin.
Il flex era sulla td e spezzava vertical-align; ora sta nel contenuto e l'ingest è su una riga.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:24:18 +02:00
eminuxandCursor 60dfe7a75b Allinea verticalmente le righe della tabella sessioni admin.
Il flex era sulla td e spezzava vertical-align; ora sta nel contenuto e l'ingest è su una riga.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:21:03 +02:00
eminuxandCursor a07f231417 Permette titolo, testo e link diversi per Android e iOS negli avvisi.
Separa i canali app e mostra il nodo ingest nelle sessioni admin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:09:04 +02:00
eminuxandCursor 43d5003de8 Completa UI e test del form contatti.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 15:39:11 +02:00
eminuxandCursor eadfc29da3 Corregge YAML form contatti: quota form_lead con due punti.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 15:37:45 +02:00
eminuxandCursor 50d3e2cf16 Completa traduzioni privacy, mailer e flash del form contatti.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 15:31:52 +02:00
eminuxandCursor 55e71ca838 Allinea privacy DE/ES/FR al form contatti.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 15:31:07 +02:00
eminuxandCursor 5b723bf844 Aggiunge annunci in-app/sito, contatti e migliora UI copertina sul portale.
Include admin/API/banner nativi, form contatti e reset copertina standard con layout edit più ampio.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 15:30:49 +02:00
eminuxandCursor e502844b88 fix: non bloccare go-live se agent slate Hetzner è down
Se il push della cover custom all'agent fallisce (timeout/connection refused),
usa lo slate di default invece di abortire la creazione sessione.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 13:26:52 +02:00
eminuxandCursor 0702cb699c Rendi affidabile pausa con slate e conferma i controlli laterali.
Evita buchi HLS/YouTube in pausa (path slate ricreata, reload player, no patch recording inutili) e richiede conferma su tasti laterali app/regia.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 12:44:23 +02:00
eminuxandCursor e09b3ffcf8 Corregge validazione upload copertina e generazione slate al go-live.
Rifiuta file troppo grandi o non-immagine via Marcel prima di attach, e genera la slate in sync anche quando cover_image esiste ma cover_slate non è ancora pronta.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 09:51:33 +02:00
eminuxandCursor 41d4235258 Aggiunge copertina sponsor custom solo Premium Full, con override in wizard e slate sui nodi di streaming.
Permette upload da portale e app con ereditarietà partita→squadra→società, generazione MP4 via ffmpeg e distribuzione su home lab e nodi CPX per pause e assenza segnale.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 23:47:17 +02:00
eminuxandCursor a51d5e8da5 Non inviare in automatico le istruzioni di bonifico.
IBAN e causale restano da comunicare a mano dall'admin; la richiesta registra solo l'ordine in attesa.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 18:40:45 +02:00
eminuxandCursor 5bcb4170c7 Mostra il piano attivo dopo il bonifico e allinea il flusso società.
Dopo la conferma admin la card restava su «Paga con bonifico»; la società parte da Free, l'owner è staff trasmissione e le mail non bloccano se manca SMTP.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 18:31:39 +02:00
eminuxandCursor e436f5ece4 Aggiunge pagamento con bonifico e importo concordato per società.
Il piano si attiva solo dopo la conferma admin; Stripe resta a listino se non c'è un prezzo commerciale.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 23:20:43 +02:00
eminuxandCursor 1e4e0560f1 Allinea le spec alla policy password e prepara i piani di test.
Senza questo la suite locale fallisce sulla complessità password e sui piani mancanti.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 23:20:31 +02:00
eminuxandCursor 841a6a15be Avvia i relay YouTube overflow dal Sidekiq home e passa STREAM/HCLOUD in produzione.
Così un CPX nuovo non richiede worker Docker a mano, l'agent è nel cloud-init e gli E2E restano non in elenco.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 08:57:56 +02:00
eminuxandCursor dd94bf7e66 Riprova le GET Hetzner su errori SSL transitori e aggiunge i loghi con nome.
Durante il wait del CPX l'API chiudeva la connessione TLS; i tre PNG servono le varianti dark/white del marchio.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 22:56:46 +02:00
eminuxandCursor baa6283a15 Mantiene la copertina YouTube se il telefono cade e avvia ffmpeg sul nodo, non via SSH.
Il relay HLS→RTMPS resta sul worker/agent del nodo assegnato, così tre dirette contemporanee restano in onda sul sito e sul canale della società senza schermo nero.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 20:41:46 +02:00
eminux bfb3115a64 Merge branch 'main' into collaudo
Porta in collaudo l'invito trasmissione con email automatica.
2026-08-15 20:02:40 +02:00
eminuxandCursor db8b14ba4e Snellisci l'invito a trasmettere e invia l'email automatica.
Il form invito è sulla pagina squadra, genera il link e manda la mail; in caso di errore SMTP resta la copia manuale.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 20:02:25 +02:00
eminux 46244fe066 Merge branch 'main' into collaudo
Porta su collaudo i prezzi di lancio, la pagina Funzionalità e i badge store.
2026-08-14 23:09:20 +02:00
eminuxandCursor 3ed70b6999 Aggiorna marketing: prezzi lancio, Funzionalità narrativa e link store.
Allinea i piani Light/Full ai prezzi di lancio, ripensa /funzionalita per vendere il prodotto e aggiunge i badge App Store/Play nella home.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 23:00:17 +02:00
eminuxandCursor b05b1acd7a Usa l'hostname pubblico per l'ingest RTMP di collaudo.
I telefoni in 4G non raggiungono l'IP LAN; la NAT sulla 11935 espone collaudo.matchlivetv.it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 23:26:34 +02:00
eminuxandCursor c777853c97 Rende autonomo lo spec Hetzner fornendo cloud-init inline.
Così il test di create_node non dipende dal file montato in collaudo.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 20:24:37 +02:00
eminuxandCursor 9225318e1a Aggiunge il flavor Android collaudo installabile accanto all'app di produzione.
Usa un applicationId distinto, icona con fascia gialla e API su collaudo.matchlivetv.it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 20:19:20 +02:00
eminuxandCursor 061b7fad8b Merge branch 'feature/adaptive-bitrate' into collaudo
Integra bitrate adattivo e telemetria, con il mute audio come icona nella toolbar della regia.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 20:16:26 +02:00
eminuxandCursor be7957c078 Merge branch 'feature/stream-audio-mute' into collaudo
Integra il mute del microfono in diretta per il collaudo.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 20:10:27 +02:00
eminux 91667e1bfd Merge branch 'feature/streaming-autoscale-hetzner' into collaudo
Integra l'autoscaler streaming Hetzner nell'ambiente di collaudo.
2026-08-13 20:10:11 +02:00
eminuxandCursor f92ab3a813 Aggiunge bitrate adattivo con qualità minima da regia e telemetria del telefono.
L'encoder scende in congestione e risale piano; la regia imposta il pavimento, mostra lo stato del telefono e apre il selettore dal bitrate colorato.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 20:05:16 +02:00
eminuxandCursor 1fecccaafd Aggiunge il mute del microfono in diretta per evitare claim YouTube sulla musica in palestra.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 22:38:05 +02:00
eminux d100c8030f Merge branch 'main' into feature/streaming-autoscale-hetzner 2026-08-11 19:45:27 +02:00
eminuxandCursor bc2257efd2 Aggiunge la pagina pubblica /support compatibile con App Store Review.
Fornisce assistenza multilingua senza CTA commerciali e riusa l’email di supporto configurabile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 19:37:20 +02:00
eminuxandCursor f1906517a9 Rende leggibile la pagina admin Nodi streaming con KPI e toolbar.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 19:04:12 +02:00
eminuxandCursor 00fde25c50 Evita scale-in del nodo appena creato nello stesso reconcile.
Con IDLE_MINUTES=0 (o race stretta) lo scale-out veniva annullato subito; lo smoke AutoscalerJob Cloud su collaudo ora completa scale-out e scale-in.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 18:42:35 +02:00
eminuxandCursor 4c5af99efa Allinea slate Cloud a AAC 48k mono e preferisce home in allocate.
Senza questo il telefono (mono 48k) veniva rifiutato sui nodi Hetzner e lo warm spare rubava le sessioni a home.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 18:27:13 +02:00
eminuxandCursor 8bf12e7721 Bump versione Android a 2.0.11-native (versionCode 32).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 18:10:08 +02:00
eminuxandCursor 5a0ca8ef1c Corregge cloud-init montato, PublisherOnline multi-nodo e E2E locale-aware.
Senza volume stream-node i nodi Hetzner nascevano senza MediaMTX; sync live usa ora Client.for_session e rtmpconns (MediaMTX 1.20).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 08:48:28 +02:00
eminuxandCursor 714602f3da Aggiunge overlay Compose per collaudo con RTMP su porta 11935.
Isola MediaMTX dal :1935 di produzione sullo stesso IP pubblico e documenta env/helper per il container Proxmox di test.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 20:29:47 +02:00
eminuxandCursor baac3a512b Preserva drain/offline su home e genera slate offline nel cloud-init.
Evita che ensure_home_from_env! annulli il drain a ogni allocate, e prepara /slates/offline.mp4 sul nodo Cloud per create_path MediaMTX.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 12:29:23 +02:00
eminuxandCursor 559284f0b2 Corregge il provisioning Hetzner: Faraday, cloud-init e default cpx12.
Sistemati path API /v1, AppArmor/auth MediaMTX sul nodo e defaults nbg1 così lo smoke Cloud è ripetibile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 12:20:36 +02:00
eminuxandCursor e5fc925bae Completa il hardening autoscaler (Fase 4): budget, kill-switch e runbook.
Drain sicuro in scale-in, alert Ops su overflow e controlli admin senza abilitare il deploy in produzione.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 20:00:15 +02:00
eminuxandCursor 318a319608 Aggiunge l'autoscaler streaming con warm spare e kill-switch.
Scala i nodi overflow quando gli slot calano, mantiene una spare a caldo sotto carico e spegne gli idle, disattivabile con STREAM_AUTOSCALE_ENABLED.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 19:56:58 +02:00
eminuxandCursor 0b55d5a8fa Rende sticky e capacizzati i relay YouTube su coda dedicata.
Evita doppi ffmpeg tra host: stop solo sull'owner, ensure con requeue a capacità piena e coda Sidekiq youtube_relay isolata.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 19:54:04 +02:00
eminuxandCursor c3878cdc6d Aggiunge registry nodi stream e provisioning Hetzner/lab per lo scale-out.
Prepara l'architettura multi-nodo (MediaMTX+ffmpeg) con assignment URL per sessione, admin di provision/drain e provider Cloud/DNS astratti verso mltv-stream.net.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 19:52:47 +02:00
Emiliano FrascaroandCursor 4af4fa68ac Prepara iOS build 32 per App Store: icona opaca e orientamenti iPad.
Rimuove l'alpha dall'AppIcon e dichiara tutte le orientazioni richieste dal multitasking iPad, così l'upload ASC non fallisce più.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 22:58:27 +02:00
Emiliano FrascaroandCursor 45bdda7c95 Semplifica la home partite a un solo CTA e sistema la nav iOS.
Rimuove il pulsante ridondante «Partita programmata», migliora il padding dei bottoni e fa di splash/login/matches root vere così non compare più il chevron indietro spurio.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 18:54:44 +02:00
Emiliano FrascaroandCursor b926531447 Allinea la versione iOS a Android 2.0.10 e aggiorna la doc di gap.
Bump marketing/build a 2.0.10/31, copy EN forgot password e checklist password policy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 18:39:43 +02:00
eminux da6fc1d523 Merge branch 'feature/password-policy'
Policy password, errori API localizzati, fix UI mobile marketing e handoff iOS.
2026-08-08 14:19:35 +02:00
eminuxandCursor 1d1cbf9f3f Localizza errori API, sistema layout mobile e documenta allineamento iOS.
Gli header Accept-Language dalle app e i fix di padding/menu sul web chiudono il giro password-policy; il doc guida il lavoro su Mac.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 14:14:42 +02:00
eminuxandCursor dd9901519a Rifiuta il riuso della password attuale in cambio e reset.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 11:31:47 +02:00
eminuxandCursor 53449c5d3c Allinea le password di seed alla nuova policy di complessità.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 11:30:51 +02:00
eminuxandCursor db908e3109 Rafforza i requisiti password con regole di complessità di mercato.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 11:30:37 +02:00
eminux 5857f60d79 Merge branch 'feature/account-management'
Gestione account MVP: profilo, cambio password e forgot password su web/Android/iOS.
2026-08-08 10:39:08 +02:00
eminuxandCursor 83a804a709 Aggiunge gestione account con cambio password su web e app.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 10:38:57 +02:00
eminuxandCursor b8694a6b7b Corregge i simboli nativi AAB in formato .so.sym per Play Console.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:45:29 +02:00
eminuxandCursor 5daada81b0 Incorpora i simboli nativi nell’AAB per Play Console.
Le .so AndroidX sono già stripped: AGP non generava metadata; ora le iniettiamo nel bundle prima della firma (release 2.0.9 / 30).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:36:39 +02:00
eminuxandCursor d85bab30d1 Ignora i secret Garage locali nel deploy sync.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:27:25 +02:00
eminuxandCursor 7b3256c8a0 Alleggerisce il relay YouTube e rilascia Android 2.0.8.
ffmpeg fa solo remux copy A/V; le app fissano AAC 48 kHz mono; sync deploy non cancella più garage.prod.toml.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:25:40 +02:00
eminuxandCursor 9bc89fceba Allinea il fingerprint Garage negli health check ops.
Così al ripristino dello storage gli incidenti garage_storage:head si chiudono da soli invece di restare aperti.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 16:29:03 +02:00
eminuxandCursor d5ec266437 Pubblica App Links Android e prepara la release 2.0.7.
Serve assetlinks su edge/Rails, documenta overflow Hetzner e bumpa l'AAB a 28 con simboli nativi per Play.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 19:44:17 +02:00
eminuxandCursor a448ad59ee Allinea i doc al flusso live senza OverlayRelay.
Documenta overlay in app, YoutubeRelay copy+AAC in Sidekiq
e HLS sul path camera; aggiorna checklist e troubleshooting.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 23:05:50 +02:00
eminuxandCursor 74a156cedc Corregge i contatti email al dominio matchlivetv.it.
I mailto di prezzi/billing e i default privacy/mailer puntavano
ancora a matchlive.it, dominio brand incoerente.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 14:36:42 +02:00
eminuxandCursor 97d787f758 Bump Android a 2.0.6 e corregge la compilazione termica.
Alza versionCode a 27 per il test chiuso Play Console e sistema
l'etichetta Kotlin invalida in ThermalStateManager.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 15:02:35 +02:00
Emiliano FrascaroandCursor ea5da1eb86 Allinea iOS a i18n Android 2.0.5 e aggiunge monitoraggio termico nativo.
Completa L10n su login/hub/wizard/broadcast e introduce ThermalStateManager su iOS/Android con degradazione qualità e indicatore in overlay.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 11:19:37 +02:00
eminuxandCursor ff4de33cc5 Stabilizza i18n Android 2.0.5: lingua live senza perdere sessione né crashare il wizard.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 09:36:56 +02:00
eminuxandCursor 97af398654 Aggiorna il gap iOS per replicare i18n Android 2.0.4 completo.
Checklist a fasi (stringhe, hub/sheet, wizard, broadcast, bump versione) allineata al rilascio sul telefono.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 08:48:55 +02:00
eminuxandCursor 0a9185347f Aggiorna il gap doc iOS con la copertura i18n Android 2.0.4.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 08:41:28 +02:00
eminuxandCursor 5ff22c7586 Completa i18n Android su sheet, wizard, broadcast e login.
Selettore lingua con icona globo al login; dialog e bottom sheet (es. scegli squadra) tradotti; bump a 2.0.4-native.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 08:40:52 +02:00
eminuxandCursor adceb8f13f Documenta il gap i18n Android→iOS per continuare sul Mac.
Checklist operativa con file, chiavi stringa e bump versione per allineare l’app iOS alla release Android 2.0.3.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 01:04:42 +02:00
eminuxandCursor f2498a9761 Allinea le expectation billing agli stringhe i18n.
Le assertion usavano HTML/testi pre-traduzione (strong e label Attiva).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 00:52:21 +02:00
eminuxandCursor b88f44ab1c Completa i18n IT/EN/FR/DE/ES su sito pubblico, area autenticata e admin.
Tutte le view marketing, legali, viewer, dashboard e admin usano t(); mailer utente-facing e flash localizzati; admin con LocaleResolver e selettore lingua.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 00:38:50 +02:00
eminuxandCursor fb7068f86c Fix deploy migrate su immagine nuova e bump Android 2.0.3.
Le migrate partono dall'immagine appena buildata invece che dal container vecchio; helper live tollera slug assente.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 23:31:17 +02:00
eminuxandCursor 1d97271555 Aggiunge i18n IT/EN/FR/DE/ES, pagine pubbliche squadre e UX archivio.
Il selettore lingua funziona sul web e sulle app native; su Android la preferenza è persistita e applicata al riavvio. Incluse anche eliminazione replay a scope e campi pagina pubblica squadra.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 19:30:44 +02:00
eminuxandCursor 7d5d66840f Riduce reload MediaMTX in live e allinea ingest mobile al preset qualità.
Evita patch recording ripetute che distruggevano i muxer HLS (500 ops) e fa encoderare 1080p/720p con codec e bitrate coerenti al wizard.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 22:17:18 +02:00
Emiliano FrascaroandCursor e20c2ae619 Accetta undo punteggio dalla regia sull'overlay iOS.
applyRemote ignorava aggiornamenti Action Cable con progressKey più basso, quindi il meno in regia non aggiornava il tabellone sullo stream.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-20 16:28:36 +02:00
Emiliano FrascaroandCursor 793cfaaaf1 Fix avanzamento quarto basket e regia senza cronometro.
Normalizza il periodo nel backend quando l'app invia "Q1" via cable, così advance_period aggiorna regia e overlay; rimuove i controlli cronometro dalla regia e rende più tollerante il decode iOS al resume sessione.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-20 16:15:44 +02:00
Emiliano FrascaroandCursor c92099a41b Sincronizza overlay video con punteggio dalla regia remota.
L'iPad accetta gli aggiornamenti ActionCable della regia, evita race con risposte API obsolete e ridisegna subito il tabellone sullo stream RTMP.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-20 12:37:46 +02:00
Emiliano FrascaroandCursor 270bf680a4 Fix regia basket e stabilità diretta iOS.
La regia remota non va più in 500 su basket/timed; iOS gestisce chiusura set, riconnessione RTMP e avanzamento quarti in modo affidabile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-20 12:30:34 +02:00
Emiliano FrascaroandCursor 731b43ea88 Stabilizza diretta iOS e aggiunge test simulatore pre-release.
Corregge lifecycle RTMP/preview tra partite, reset tabellone, orientamento
con debounce, condivisione su iPad e script test_ios_simulator per CI locale.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-20 12:02:14 +02:00
Emiliano FrascaroandCursor 0f69ebcccb Instrada /hls/ su Rails per evitare redirect HTTP nel browser.
MediaMTX reindirizzava a http:// su pagine HTTPS; il proxy Rails segue i redirect lato server.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 19:15:04 +02:00
Emiliano FrascaroandCursor 5c85eb3524 Fix HLS browser: redirect HTTPS e proxy /live/match_* su Rails.
MediaMTX reindirizzava a http:// (bloccato in mixed content) e nginx riscriveva male il path live/.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 19:13:19 +02:00
Emiliano FrascaroandCursor a303a94671 Fix HLS browser in prod e stabilizza broadcast RTMP su iOS.
Il proxy Rails gestisce il redirect MediaMTX su /live/match_* e riscrive le playlist; edge nginx reindirizza /live/ sotto /hls/. Su iOS, SessionBuilder HaishinKit, pipeline mixer corretta e refresh token.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 18:48:29 +02:00
eminuxandCursor ed0f913138 Restringe pattern ops sidekiq_failure per evitare falsi allarmi.
Non segnala più WARN generici come delete_path failed; solo job ActiveJob/Sidekiq realmente falliti.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 18:32:26 +02:00
eminuxandCursor d8af931bfb Rende idempotente il delete path MediaMTX (404 = già rimosso).
Evita errori quando il path _air non esiste o è già stato eliminato.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 21:04:36 +02:00
eminuxandCursor fbc9bb5858 Corregge delete path MediaMTX: usa HTTP DELETE.
Il POST restituiva 404 silenzioso e lasciava path alwaysAvailable attivi dopo fine diretta.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 20:59:13 +02:00
eminuxandCursor 827abf80cb Elimina i path MediaMTX orfani dopo ogni diretta.
Corregge il cleanup dei path live/match_{uuid}, rimuove sempre il path a fine sessione e lo esegue ogni ora via cron.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 20:42:40 +02:00
Emiliano FrascaroandCursor 594853ed04 Fix punteggio broadcast iOS: decodifica score e aggiornamento UI.
ScoreStateDto non leggeva home_points con convertFromSnakeCase; ScoreController assegna sempre lo stato, aggiornamento ottimistico e refresh overlay.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 07:37:27 +02:00
Emiliano FrascaroandCursor 80813b390b Fix connessione RTMP in dev: riscrivi host Docker mediamtx per iOS.
Il simulatore non risolve «mediamtx»; l'app riscrive l'ingest verso 127.0.0.1 e docker-compose espone MEDIAMTX_RTMP_URL client-safe con messaggi d'errore RTMP più chiari.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 07:30:06 +02:00
Emiliano FrascaroandCursor 5de3a1a1cf Aggiunge E2E simulatore iOS e API_BASE_URL configurabile in build.
Permette build Debug verso localhost, script smoke per close_set/resume session e rigenerazione progetto con setting API di default produzione.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 21:43:51 +02:00
Emiliano FrascaroandCursor 585332e32e Aggiunge app iOS nativa con parità multi-sport e test unitari.
Porting SwiftUI da Android (auth, hub, wizard, broadcast RTMP, overlay, scoring board-aware), reload hub al ritorno da diretta, decodifica score tollerante e documentazione allineata.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 21:37:32 +02:00
eminuxandCursor b798e0ea06 Aggiunge asset brand per Play Store e comunicazione.
Logo 512×512, immagini copertina/primo piano e screenshot app Android per pubblicazione e materiale marketing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 21:27:09 +02:00
eminuxandCursor 498f6069d0 Documenta l'architettura completa del sistema.
Aggiunge docs/ARCHITECTURE.md con topologia rete, edge nginx, flussi HLS/replay e monitoraggio ops; allinea i documenti esistenti.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 21:26:35 +02:00
eminuxandCursor a44d9fbadd HLS su edge e monitoraggio ops con latenza p95.
Evita di saturare Puma in diretta, separa i check Rails/pubblico e traccia la latenza /up per alert più affidabili.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 21:19:32 +02:00
eminuxandCursor 13390d8a3c Replay fuori da Puma, fix ops cron e firma release Android.
Dopo auth Rails redirect a URL presigned su /media/ (edge → Garage); aumenta
RAILS_MAX_THREADS, corregge ingest log ops via stdin e keystore Play Console.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 22:59:15 +02:00
eminuxandCursor e1636139a9 Corregge ordine deploy: edge e Rails vanno avviati insieme.
Evita conflitto sulla porta 3000 quando si sposta il bind da Rails al proxy di cortesia.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 09:34:54 +02:00
eminuxandCursor 949a0a01fb Aggiunge proxy edge con pagina di cortesia e rilascio a downtime minimo.
NPM punta a nginx locale su :3000 che resta attivo durante il restart Rails; nuovo script release_production.sh esegue migrate, build e deploy in ordine.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 09:31:36 +02:00
eminuxandCursor 208664848e Rende persistenti i log di produzione e protegge il deploy.
Monta /app/log sul disco video, sposta i cron log sullo stesso path, esclude log/ dal sync rsync e aggiunge pulizia volumi Docker obsoleti.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 09:23:42 +02:00
eminuxandCursor b802cf3309 Corregge persistenza loghi Active Storage in produzione.
Monta /app/storage sul disco video dedicato invece che su un volume Docker al path sbagliato, così i logo società/squadra sopravvivono ai rebuild.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 09:16:35 +02:00
eminuxandCursor dc309fbeb0 Permette agli admin di prolungare la scadenza dei replay.
Aggiunge data personalizzata e pulsanti rapidi nell'archivio admin così il job di purge non elimina i video fino alla nuova scadenza.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 09:11:56 +02:00
eminuxandCursor 89854869c7 Aggiunge gestione archivio replay nel pannello admin.
Gli admin possono vedere, modificare ed eliminare i replay di una società come la squadra proprietaria, riusando la stessa UI e logica dell'archivio pubblico.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 09:01:38 +02:00
eminuxandCursor da75582dae Sposta storage video su disco dedicato e completa ops/SMTP in produzione.
Recordings e Garage usano bind mount su /media/videos; aggiunge ntfy self-hosted,
notifiche critical+warning, SMTP Aruba SSL/465 e fix cron replay.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 23:13:08 +02:00
eminuxandCursor 52cfffb83f Aggiunge monitoraggio ops: health check, log scanner, dashboard e notifiche.
Introduce incidenti con dedup, job Sidekiq ogni 3 min, scan log cron, /up/deep,
dashboard /admin/ops e push ntfy; include Sentry opzionale e fix test suite.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 08:16:47 +02:00
eminuxandCursor ce8939fffb Aggiunge pulizia ciclica segmenti locali e doc tabelloni/overlay.
Evita il riempimento disco da segmenti MediaMTX post-sessione con cron orario;
documenta come board, overlay e regolamenti sportivi si relazionano nel sistema.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 22:08:05 +02:00
eminuxandCursor 2b7e8b9e36 Migliora overlay basket Android, regia e catalogo sport senza timer.
Fix crash e race su +3, layout tabellone con squadre ai lati, safe area in diretta e rimozione overlay cronometro; backend con sync punteggio periodo, link diretta in regia e init score_state basket.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 23:31:12 +02:00
eminuxandCursor 34c97f4447 Fix sport squadra su partite basket e ruoli organico per board.
Le partite ereditano lo sport_key della squadra alla creazione invece del default pallavolo; l'organico mostra ruoli per board (basket, volley, timed) e il wizard Android usa lo sport del team per overlay e icona.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 22:32:36 +02:00
eminuxandCursor 01b7a3c96b Fix hub diretta: considera solo l'ultima sessione, non idle orfane.
Dopo «Termina trasmissione» la partita restava visibile se esisteva una vecchia sessione idle precedente a quella ended; l'hub e l'app usano ora lo stato dell'ultima sessione.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 21:58:01 +02:00
eminuxandCursor ad9d67ddb6 Completa multi-sport su web, API score_action e controlli Android board-aware.
Allinea i form società/squadra al catalogo sport, espone score_action per basket/timed e corregge bug namespace Sports e Result nel period engine; l'app nativa gestisce overlay e punteggio per board con wizard regole esteso.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 21:48:29 +02:00
eminuxandCursor c0ede48091 Fix: rinomina Engine.for in for_match (keyword riservata Ruby).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 20:13:33 +02:00
eminuxandCursor e727069d43 Introduce architettura multi-sport con catalogo, engine scoring e overlay.
Catalogo sport in YAML, board implicito, engine per volley/basket/timed/racket/timer/generic, regia e app Android allineate.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 20:11:21 +02:00
eminuxandCursor 53f9a6a884 Allinea replay alla UI live e corregge miniature e loghi.
Player contenuto come la diretta, fix streaming thumbnail da S3, volume persistente ActiveStorage in produzione.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 11:01:04 +02:00
eminuxandCursor 0621efb839 Compatta archivio replay senza scroll orizzontale.
Riduce le colonne, impila dettagli e stato su due righe e usa date brevi per stare nel layout della pagina.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 09:28:03 +02:00
eminuxandCursor 214d276508 Migliora UI archivio replay società con play e azioni separate.
Anteprima cliccabile con pulsante play, badge stato, select visibilità compatti e colonna azioni senza sovrapposizioni.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 09:14:38 +02:00
eminuxandCursor 9177e9e5a2 Fix registrazione replay in produzione via PublisherSync.
MediaMTX distroless non invoca i webhook wget: accendiamo record sul path quando il telefono pubblica RTMP e lo spegniamo in pausa/offline.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 09:00:43 +02:00
eminuxandCursor 4ed15bc235 Rimuovi replay da app, fix live web e sync punteggi broadcast.
La pagina live mostra solo l'overlay nel video (niente tabellone HTML); l'app nativa non espone più l'archivio replay, il toggle regole punteggio parte spento e i controlli in diretta seguono il punteggio dalla regia.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 08:42:23 +02:00
eminuxandCursor 74eee24293 Completa modulo Replay: S3, retention, sync YouTube e gestione società.
Streaming chunked da Garage, purge con delete YouTube, privacy sincronizzata,
archivio club migliorato, retention 30/90 separata da stato abbonamento.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 09:33:59 +02:00
eminuxandCursor ae36d17adb Stabilizza regia, live web e sync punteggi app nativa.
Corregge scadenza token regia oltre le 8h, tabellone HTML sulla pagina live,
pulsanti pausa/ripresa in regia, link admin e broadcast ActionCable diretto.
App Android: overlay branding, sync score da regia via WebSocket con reconnect e poll.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 09:30:43 +02:00
eminuxandCursor 08e800120a Allinea hub partite app nativa e API match al wizard semplificato.
Filtra le partite visibili lato client/server, corregge il parsing date Rails su Android, semplifica lo step Partita del wizard e restringe l'API ai campi effettivamente usati (campionato, set e scoring_rules).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-07 10:49:30 +02:00
814 changed files with 63137 additions and 3829 deletions
+7 -2
View File
@@ -58,8 +58,13 @@ Vedi [native/android/README.md](native/android/README.md).
## Architettura
- Video (MVP): Phone → MediaMTX (RTMP) → HLS su `matchlivetv.*` (pagina `/live/:session_id`)
- Video (premium): stesso ingest + relay ffmpeg verso YouTube
Documentazione completa: **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)** (rete, edge nginx, flussi video, ops).
In sintesi:
- Video live: Phone → MediaMTX (RTMP) → HLS via edge `/hls/` (non Puma)
- Video replay: Rails auth → redirect `/media/` → Garage (non Puma)
- Video premium: stesso ingest + relay ffmpeg verso YouTube
- Controllo: Phone ↔ Action Cable ↔ Rails ↔ PostgreSQL
- Disconnessioni: MediaMTX `alwaysAvailable` slate + timeout 5 min (Sidekiq)
+2
View File
@@ -19,6 +19,8 @@ gem "faraday"
gem "stripe"
gem "aws-sdk-s3", "~> 1.0", require: false
gem "kamal", require: false
gem "sentry-ruby"
gem "sentry-rails"
group :development, :test do
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"
+9
View File
@@ -381,6 +381,13 @@ GEM
rubocop-rails (>= 2.30)
ruby-progressbar (1.13.0)
securerandom (0.4.1)
sentry-rails (6.6.2)
railties (>= 5.2.0)
sentry-ruby (~> 6.6.2)
sentry-ruby (6.6.2)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
logger
sidekiq (8.1.5)
connection_pool (>= 3.0.0)
json (>= 2.16.0)
@@ -456,6 +463,8 @@ DEPENDENCIES
redis (>= 4.0.1)
rspec-rails
rubocop-rails-omakase
sentry-rails
sentry-ruby
sidekiq
stripe
tzinfo-data
@@ -3,7 +3,7 @@ module CableBroadcastable
class_methods do
def broadcast_message(session, data)
broadcast_to(session, method: "receive_message", data: data)
broadcast_to(session, data)
end
end
end
+6
View File
@@ -55,6 +55,12 @@ class SessionChannel < ApplicationCable::Channel
Sessions::Pause.new(session).call
when "resume_stream"
Sessions::Resume.new(session).call
when "mute_audio"
Sessions::SetAudioMute.new(session, muted: true).call
when "unmute_audio"
Sessions::SetAudioMute.new(session, muted: false).call
when "set_min_quality"
Sessions::SetMinQuality.new(session, preset: data["min_quality_preset"]).call
end
end
@@ -0,0 +1,101 @@
# frozen_string_literal: true
module Admin
class AnalyticsController < Admin::BaseController
def index
@filters = {
from: parse_date(params[:from]) || 7.days.ago.to_date,
to: parse_date(params[:to]) || Time.zone.today,
device: params[:device].presence
}
scope = AnalyticsPageStat.where(day: @filters[:from]..@filters[:to])
scope = scope.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
cell_scope = AnalyticsPageCell.where(day: @filters[:from]..@filters[:to])
cell_scope = cell_scope.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
clicks_by_path = cell_scope.group(:page_path).sum(:click_count)
moves_by_path = cell_scope.group(:page_path).sum(:move_count)
pageviews_by_path = scope.group(:page_path).sum(:pageview_count)
scroll_samples_by_path = scope.group(:page_path).sum(:scroll_samples)
scroll_sum_by_path = scope.group(:page_path).sum(:scroll_sum_pct)
max_scroll_by_path = scope.group(:page_path).maximum(:max_scroll_pct)
paths = (pageviews_by_path.keys + clicks_by_path.keys + moves_by_path.keys).uniq
@pages = paths.map do |path|
samples = scroll_samples_by_path[path].to_i
{
page_path: path,
pageviews: pageviews_by_path[path].to_i,
clicks: clicks_by_path[path].to_i,
moves: moves_by_path[path].to_i,
scroll_samples: samples,
scroll_sum: scroll_sum_by_path[path].to_i,
max_scroll: max_scroll_by_path[path].to_i
}
end.sort_by { |r| [-r[:pageviews], -r[:moves], -r[:clicks], r[:page_path]] }
end
def show
@page_path = params[:page_path].to_s
redirect_to admin_analytics_path, alert: t("admin.analytics.missing_path") and return if @page_path.blank?
@filters = {
from: parse_date(params[:from]) || 7.days.ago.to_date,
to: parse_date(params[:to]) || Time.zone.today,
device: params[:device].presence,
layer: params[:layer].to_s
}
cells = AnalyticsPageCell.where(page_path: @page_path, day: @filters[:from]..@filters[:to])
cells = cells.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
@click_total = cells.sum(:click_count)
@move_total = cells.sum(:move_count)
@filters[:layer] =
if %w[move click].include?(@filters[:layer])
@filters[:layer]
elsif @move_total.positive?
"move"
else
"click"
end
counter = @filters[:layer] == "click" ? :click_count : :move_count
@cells = cells.group(:cell_x, :cell_y).sum(counter)
@max_weight = @cells.values.max.to_i
@total_points = @cells.values.sum
stats = AnalyticsPageStat.where(page_path: @page_path, day: @filters[:from]..@filters[:to])
stats = stats.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
@pageviews = stats.sum(:pageview_count)
@scroll_samples = stats.sum(:scroll_samples)
@scroll_sum = stats.sum(:scroll_sum_pct)
@max_scroll = stats.maximum(:max_scroll_pct).to_i
@avg_scroll = @scroll_samples.positive? ? (@scroll_sum.to_f / @scroll_samples).round : 0
@grid = AnalyticsPageCell::GRID_SIZE
@snapshot = find_snapshot(@page_path, @filters[:device])
end
private
def parse_date(value)
return nil if value.blank?
Date.parse(value.to_s)
rescue ArgumentError, TypeError
nil
end
def find_snapshot(page_path, device)
scope = AnalyticsPageSnapshot.where(page_path: page_path)
if device.present? && AnalyticsEvent::DEVICES.include?(device)
snap = scope.find_by(device: device)
return snap if snap&.image&.attached?
end
scope.order(captured_at: :desc).detect { |s| s.image.attached? }
end
end
end
@@ -0,0 +1,76 @@
module Admin
class AnnouncementsController < Admin::BaseController
before_action :set_announcement, only: %i[edit update destroy]
def index
@announcements = AppAnnouncement.newest
end
def new
@announcement = AppAnnouncement.new(
kind: "info",
severity: "info",
status: "draft",
show_on_android: true,
show_on_ios: true,
show_on_web_public: true,
show_on_web_private: true,
dismissible: true
)
end
def create
@announcement = AppAnnouncement.new(announcement_params)
@announcement.created_by_admin = current_admin_account
if @announcement.save
redirect_to admin_announcements_path, notice: t("admin.flash.announcement_created")
else
flash.now[:alert] = @announcement.errors.full_messages.join(", ")
render :new, status: :unprocessable_entity
end
end
def edit; end
def update
if @announcement.update(announcement_params)
redirect_to admin_announcements_path, notice: t("admin.flash.announcement_updated")
else
flash.now[:alert] = @announcement.errors.full_messages.join(", ")
render :edit, status: :unprocessable_entity
end
end
def destroy
@announcement.destroy!
redirect_to admin_announcements_path, notice: t("admin.flash.announcement_destroyed")
end
private
def set_announcement
@announcement = AppAnnouncement.find(params[:id])
end
def announcement_params
params.require(:app_announcement).permit(
:kind, :severity, :status, :title, :body,
:action_url, :action_label, :dismissible, :starts_at, :ends_at,
:show_on_android, :show_on_ios, :show_on_web_public, :show_on_web_private,
translations: {
en: AppAnnouncement::COPY_KEYS,
fr: AppAnnouncement::COPY_KEYS,
de: AppAnnouncement::COPY_KEYS,
es: AppAnnouncement::COPY_KEYS
}
).tap do |permitted|
%i[dismissible show_on_android show_on_ios show_on_web_public show_on_web_private].each do |key|
permitted[key] = ActiveModel::Type::Boolean.new.cast(permitted[key])
end
%i[starts_at ends_at action_url action_label].each do |key|
permitted[key] = nil if permitted[key].blank?
end
end
end
end
end
@@ -1,6 +1,8 @@
module Admin
class AuthController < Admin::BaseController
skip_before_action :require_admin_login, only: %i[new create]
# Same as public logout: tolerate stale CSRF after long-lived admin tabs.
skip_before_action :verify_authenticity_token, only: :destroy
def new
redirect_to admin_root_path if admin_logged_in?
@@ -10,16 +12,16 @@ module Admin
account = AdminAccount.find_by(username: params[:username]&.strip)
if account&.authenticate(params[:password])
session[:admin_account_id] = account.id
redirect_to session.delete(:admin_return_to) || admin_root_path, notice: "Accesso effettuato"
redirect_to session.delete(:admin_return_to) || admin_root_path, notice: t("admin.flash.login_success")
else
flash.now[:alert] = "Username o password non validi"
flash.now[:alert] = t("admin.flash.invalid_credentials")
render :new, status: :unauthorized
end
end
def destroy
reset_session
redirect_to admin_login_path, notice: "Disconnesso"
redirect_to admin_login_path, notice: t("admin.flash.logout_success")
end
end
end
@@ -3,18 +3,40 @@ module Admin
layout "admin"
protect_from_forgery with: :exception
before_action :set_admin_locale
before_action :require_admin_login
include ::AdminHelper
helper_method :current_admin_account, :admin_logged_in?
helper ApplicationHelper
helper RecordingsArchiveHelper
helper_method :current_admin_account, :admin_logged_in?, :current_locale, :language_options, :current_language_option
private
def set_admin_locale
I18n.locale = LocaleResolver.resolve(
cookie_jar: request.cookie_jar,
accept_language: request.get_header("HTTP_ACCEPT_LANGUAGE")
)
end
def current_locale
I18n.locale
end
def language_options
LocaleResolver.language_options
end
def current_language_option
LocaleResolver.current_language_option
end
def require_admin_login
return if admin_logged_in?
session[:admin_return_to] = request.fullpath unless request.path == admin_login_path
redirect_to admin_login_path, alert: "Accedi per continuare"
redirect_to admin_login_path, alert: t("admin.flash.login_required")
end
def current_admin_account
@@ -2,6 +2,7 @@ module Admin
class BillingController < BaseController
def index
@pending_payments = pending_scope.recent
@pending_transfers = transfer_scope
@completed_payments = Billing::Payment.with_invoice_pdf.includes(:club, :invoice).recent.limit(40)
@filter_club = Club.find_by(id: params[:club_id]) if params[:club_id].present?
@clubs = Club.order(:name)
@@ -11,13 +12,32 @@ module Admin
payment = Billing::Payment.find(params[:payment_id])
Billing::AttachPaymentInvoice.call(payment: payment, pdf: params[:pdf])
redirect_to admin_billing_path(billing_redirect_params(payment)),
notice: "Fattura caricata e inviata a #{payment.club.billing_email}."
notice: t("admin.flash.invoice_uploaded", email: payment.club.billing_email)
rescue Billing::AttachPaymentInvoice::Error, Billing::IssueInvoice::Error => e
payment ||= Billing::Payment.find_by(id: params[:payment_id])
redirect_to admin_billing_path(payment ? billing_redirect_params(payment) : {}),
alert: e.message
end
def confirm_transfer
order = Billing::TransferOrder.find(params[:id])
Billing::ConfirmBankTransfer.call(
order: order,
admin: current_admin_account,
pdf: params[:pdf]
)
redirect_to admin_billing_path(club_id: order.club_id),
notice: t("admin.flash.transfer_confirmed", plan: order.plan.name, club: order.club.name)
rescue Billing::ConfirmBankTransfer::Error, Billing::AttachPaymentInvoice::Error, Billing::IssueInvoice::Error => e
redirect_to admin_billing_path, alert: e.message
end
def cancel_transfer
order = Billing::TransferOrder.find(params[:id])
order.cancel!
redirect_to admin_billing_path(club_id: order.club_id), notice: t("admin.flash.transfer_cancelled")
end
private
def pending_scope
@@ -26,6 +46,12 @@ module Admin
scope
end
def transfer_scope
scope = Billing::TransferOrder.awaiting_payment.includes(:club, :requested_by_user)
scope = scope.where(club_id: params[:club_id]) if params[:club_id].present?
scope
end
def billing_redirect_params(payment)
{ club_id: payment.club_id, anchor: "payment-#{payment.id}" }.compact
end
@@ -8,7 +8,7 @@ module Admin
end
def new
redirect_to admin_billing_path(club_id: @club.id), alert: "Carica il PDF dalla lista «Pagamenti da fatturare»."
redirect_to admin_billing_path(club_id: @club.id), alert: t("admin.flash.invoice_upload_hint")
end
def create
@@ -18,7 +18,7 @@ module Admin
if @invoice.save
redirect_to edit_admin_club_billing_invoice_path(@club, @invoice),
notice: "Bozza creata. Carica il PDF dalla pagina Fatturazione o qui sotto."
notice: t("admin.flash.invoice_draft_created")
else
@payment = @invoice.billing_payment
flash.now[:alert] = @invoice.errors.full_messages.join(", ")
@@ -36,9 +36,9 @@ module Admin
if issuing?
Billing::IssueInvoice.call(invoice: @invoice, pdf: params.dig(:billing_invoice, :pdf))
redirect_to admin_billing_path(club_id: @club.id),
notice: "Fattura #{@invoice.number} emessa e inviata a #{@club.billing_email}."
notice: t("admin.flash.invoice_issued", number: @invoice.number, email: @club.billing_email)
elsif @invoice.save
redirect_to admin_billing_path(club_id: @club.id), notice: "Fattura #{@invoice.number} aggiornata."
redirect_to admin_billing_path(club_id: @club.id), notice: t("admin.flash.invoice_updated", number: @invoice.number)
else
@payment = @invoice.billing_payment
flash.now[:alert] = @invoice.errors.full_messages.join(", ")
@@ -61,7 +61,7 @@ module Admin
end
def issuing?
params[:commit].to_s == "Emetti e invia via email"
params[:commit_action].to_s == "issue"
end
def invoice_params
@@ -0,0 +1,15 @@
module Admin
class ClubRecordingsController < BaseController
include Recordings::ClubArchiveActions
private
def archive_namespace
:admin
end
def authorize_recording_management!
# Admin può gestire tutti i replay della società senza vincoli di ruolo utente.
end
end
end
@@ -1,9 +1,9 @@
module Admin
class ClubsController < BaseController
before_action :set_club, only: %i[show grant_comped revoke_comped]
before_action :set_club, only: %i[show grant_comped revoke_comped set_quote revoke_quote]
def index
@clubs = Club.includes(:teams, subscription: %i[plan admin_comped_by])
@clubs = Club.includes(:teams, :billing_quote, subscription: %i[plan admin_comped_by])
.order(:name)
end
@@ -11,6 +11,7 @@ module Admin
@subscription = @club.subscription || @club.build_subscription(plan: Plan["free"], status: "active")
@plans = Plan.ordered.reject { |p| p.slug == "free" }
@teams = @club.teams.order(:name)
@quote = @club.active_billing_quote
end
def grant_comped
@@ -20,18 +21,44 @@ module Admin
reason: params[:reason],
admin: current_admin_account
)
redirect_back_or_club notice: "Abbonamento omaggio #{Plan[params[:plan_slug]].name} attivato per #{@club.name}."
redirect_back_or_club notice: t("admin.flash.comped_granted", plan: Plan[params[:plan_slug]].name, club: @club.name)
rescue Billing::AdminCompedSubscription::Error, ActiveRecord::RecordInvalid => e
redirect_back_or_club alert: e.message
end
def revoke_comped
Billing::AdminCompedSubscription.revoke(club: @club, admin: current_admin_account)
redirect_back_or_club notice: "Abbonamento omaggio revocato per #{@club.name}."
redirect_back_or_club notice: t("admin.flash.comped_revoked", club: @club.name)
rescue Billing::AdminCompedSubscription::Error => e
redirect_back_or_club alert: e.message
end
def set_quote
quote = Billing::SetClubQuote.upsert(
club: @club,
plan_slug: params.require(:plan_slug),
interval: params[:interval],
amount_euros: params[:amount_euros],
note: params[:note],
admin: current_admin_account
)
redirect_back_or_club notice: t(
"admin.flash.quote_saved",
club: @club.name,
plan: quote.plan.name,
amount: quote.formatted_amount
)
rescue Billing::SetClubQuote::Error, ActionController::ParameterMissing => e
redirect_back_or_club alert: e.message
end
def revoke_quote
Billing::SetClubQuote.revoke(club: @club, admin: current_admin_account)
redirect_back_or_club notice: t("admin.flash.quote_revoked", club: @club.name)
rescue Billing::SetClubQuote::Error => e
redirect_back_or_club alert: e.message
end
private
def set_club
@@ -5,7 +5,7 @@ module Admin
@host = HostMetrics.new.sample!
@active_sessions = StreamSession
.where(status: DashboardStats::ACTIVE_STATUSES)
.includes(match: :team)
.includes(:stream_node, match: :team)
.order(started_at: :desc)
@teams = Team.includes(:matches).order(:name).limit(8)
end
@@ -13,7 +13,22 @@ module Admin
def metrics
host = HostMetrics.new.sample!
stats = DashboardStats.new.call
render json: { host: host, stats: stats, at: Time.current.iso8601 }
render json: {
host: host,
stats: stats,
ops_summary: ops_summary,
at: Time.current.iso8601
}
end
private
def ops_summary
{
open_critical: Ops::Incident.critical_open.count,
open_warning: Ops::Incident.open.where(severity: "warning").count,
open_total: Ops::Incident.open.count
}
end
end
end
@@ -0,0 +1,11 @@
module Admin
class LocalesController < BaseController
skip_before_action :require_admin_login
def update
locale = LocaleResolver.persist!(cookies, params[:locale])
I18n.locale = locale
redirect_back fallback_location: admin_root_path
end
end
end
@@ -0,0 +1,37 @@
module Admin
class OpsController < Admin::BaseController
def index
@open_incidents = Ops::Incident.open.recent.limit(50)
@resolved_incidents = Ops::Incident.where(status: "resolved").since(30.days.ago).recent.limit(30)
@summary = ops_summary
end
def acknowledge
incident = Ops::Incident.find(params[:id])
incident.acknowledge!
redirect_to admin_ops_path, notice: t("admin.flash.ops_acknowledged")
end
def resolve
incident = Ops::Incident.find(params[:id])
incident.resolve!
redirect_to admin_ops_path, notice: t("admin.flash.ops_resolved")
end
def mute
incident = Ops::Incident.find(params[:id])
incident.mute!(duration: 24.hours)
redirect_to admin_ops_path, notice: t("admin.flash.ops_muted")
end
private
def ops_summary
{
open_critical: Ops::Incident.critical_open.count,
open_warning: Ops::Incident.open.where(severity: "warning").count,
open_total: Ops::Incident.open.count
}
end
end
end
@@ -5,22 +5,32 @@ module Admin
def update
unless current_admin_account.authenticate(params[:current_password])
flash.now[:alert] = "Password attuale non corretta"
return render :edit, status: :unprocessable_entity
end
if params[:password].blank? || params[:password].length < 8
flash.now[:alert] = "La nuova password deve avere almeno 8 caratteri"
flash.now[:alert] = t("admin.flash.password_current_incorrect")
return render :edit, status: :unprocessable_entity
end
if params[:password] != params[:password_confirmation]
flash.now[:alert] = "Le password non coincidono"
flash.now[:alert] = t("admin.flash.password_mismatch")
return render :edit, status: :unprocessable_entity
end
if (code = PasswordComplexity.violation(params[:password]))
key = case code
when :blank, :too_short then :password_too_short
when :too_long then :password_too_long
else :password_too_weak
end
flash.now[:alert] = t("admin.flash.#{key}")
return render :edit, status: :unprocessable_entity
end
if PasswordComplexity.same_as_current?(current_admin_account, params[:password])
flash.now[:alert] = t("admin.flash.password_same_as_current")
return render :edit, status: :unprocessable_entity
end
if current_admin_account.update(password: params[:password])
redirect_to admin_root_path, notice: "Password aggiornata"
redirect_to admin_root_path, notice: t("admin.flash.password_updated")
else
flash.now[:alert] = current_admin_account.errors.full_messages.to_sentence
render :edit, status: :unprocessable_entity
@@ -1,26 +1,115 @@
module Admin
class SessionsController < Admin::BaseController
def index
@sessions = StreamSession.includes(:match, :user).order(created_at: :desc).limit(50)
@filters = session_filters
scope = filtered_sessions
@total_count = scope.count
@sessions = scope
.includes(:stream_node, :user, match: { team: :club })
.order(Arel.sql("COALESCE(stream_sessions.started_at, stream_sessions.created_at) DESC"))
.limit(100)
@clubs = Club.order(:name)
@nodes = StreamNode.order(:slug)
end
def show
@session = StreamSession.find(params[:id])
@events = @session.stream_events.recent.limit(50)
@session = StreamSession
.includes(:stream_node, :user, :device_states, :recording, match: { team: :club })
.find(params[:id])
@events = @session.stream_events.recent.limit(100)
@club = @session.match.team.club
end
def stop
@session = StreamSession.find(params[:id])
if @session.terminal?
redirect_to admin_session_path(@session), alert: "Sessione già terminata (#{@session.status})."
redirect_to admin_session_path(@session), alert: t("admin.flash.session_already_terminated", status: @session.status)
return
end
Sessions::Stop.new(@session).call
redirect_to admin_session_path(@session), notice: "Sessione terminata."
redirect_to admin_session_path(@session), notice: t("admin.flash.session_stopped")
rescue StandardError => e
Rails.logger.error("[admin] stop session #{@session.id}: #{e.class} #{e.message}")
redirect_to admin_session_path(@session), alert: "Errore durante la chiusura: #{e.message}"
redirect_to admin_session_path(@session), alert: t("admin.flash.session_stop_error", error: e.message)
end
def regia_link
@session = StreamSession.find(params[:id])
if @session.terminal?
redirect_to admin_session_path(@session), alert: t("admin.flash.session_terminated_no_regia")
return
end
access = Sessions::RegiaAccess.new(@session)
token = access.issue_token!
redirect_to admin_session_path(@session),
notice: t("admin.flash.session_regia_generated"),
flash: {
regia_url: access.url(token),
regia_expires_at: @session.regia_token_expires_at&.iso8601
}
end
private
def session_filters
{
q: params[:q].to_s.strip.presence,
status: params[:status].to_s.strip.presence,
platform: params[:platform].to_s.strip.presence,
club_id: params[:club_id].to_s.strip.presence,
stream_node_id: params[:stream_node_id].to_s.strip.presence,
from: params[:from].to_s.strip.presence,
to: params[:to].to_s.strip.presence
}
end
def filtered_sessions
scope = StreamSession.left_outer_joins(match: { team: :club })
if @filters[:q].present?
term = "%#{ActiveRecord::Base.sanitize_sql_like(@filters[:q])}%"
scope = scope.where(
"teams.name ILIKE :term OR matches.opponent_name ILIKE :term OR clubs.name ILIKE :term OR matches.location ILIKE :term",
term: term
)
end
if @filters[:status].present? && StreamSession::STATUSES.include?(@filters[:status])
scope = scope.where(stream_sessions: { status: @filters[:status] })
end
if @filters[:platform].present? && StreamSession::PLATFORMS.include?(@filters[:platform])
scope = scope.where(stream_sessions: { platform: @filters[:platform] })
end
if @filters[:club_id].present?
scope = scope.where(teams: { club_id: @filters[:club_id] })
end
if @filters[:stream_node_id].present?
scope = scope.where(stream_sessions: { stream_node_id: @filters[:stream_node_id] })
end
if (from_time = parse_filter_date(@filters[:from], end_of_day: false))
scope = scope.where(
"COALESCE(stream_sessions.started_at, stream_sessions.created_at) >= ?",
from_time
)
end
if (to_time = parse_filter_date(@filters[:to], end_of_day: true))
scope = scope.where(
"COALESCE(stream_sessions.started_at, stream_sessions.created_at) <= ?",
to_time
)
end
scope
end
def parse_filter_date(value, end_of_day:)
return nil if value.blank?
date = Date.parse(value)
end_of_day ? date.end_of_day : date.beginning_of_day
rescue ArgumentError, TypeError
nil
end
end
end
@@ -0,0 +1,67 @@
# frozen_string_literal: true
module Admin
class StreamNodesController < Admin::BaseController
def index
Streams::NodeRegistry.ensure_home_from_env!
@nodes = StreamNode.order(:role, :slug)
@dns_provider = ENV.fetch("STREAM_DNS_PROVIDER", "lab")
@cloud_provider = ENV.fetch("STREAM_CLOUD_PROVIDER", "local_lab")
@lab_hosts = lab_dns_snippet
@hetzner_configured = ENV["HCLOUD_TOKEN"].present?
@autoscale_metrics = Streams::Autoscaler.metrics
end
def create
kind = params[:kind].to_s
node =
if kind == "cloud"
Streams::NodeProvisioner.new.provision_cloud!
else
Streams::NodeProvisioner.new.provision_lab!
end
redirect_to admin_stream_nodes_path,
notice: t("admin.flash.stream_node_created", slug: node.slug)
rescue Streams::NodeProvisioner::Error, Streams::CloudProviders::Error, Streams::DnsProviders::Error,
KeyError => e
redirect_to admin_stream_nodes_path, alert: e.message
end
def drain
node = StreamNode.find(params[:id])
Streams::NodeProvisioner.new.drain!(node)
redirect_to admin_stream_nodes_path, notice: t("admin.flash.stream_node_draining", slug: node.slug)
rescue Streams::NodeProvisioner::Error => e
redirect_to admin_stream_nodes_path, alert: e.message
end
def destroy
node = StreamNode.find(params[:id])
Streams::NodeProvisioner.new.decommission!(node)
redirect_to admin_stream_nodes_path, notice: t("admin.flash.stream_node_destroyed", slug: node.slug)
rescue Streams::NodeProvisioner::BusyError, Streams::NodeProvisioner::Error,
Streams::CloudProviders::Error, Streams::DnsProviders::Error => e
redirect_to admin_stream_nodes_path, alert: e.message
end
def kill_switch
Streams::Autoscaler.engage_kill_switch!
redirect_to admin_stream_nodes_path, notice: t("admin.flash.autoscale_kill_on")
end
def clear_kill_switch
Streams::Autoscaler.clear_kill_switch!
redirect_to admin_stream_nodes_path, notice: t("admin.flash.autoscale_kill_off")
end
private
def lab_dns_snippet
return "" unless @dns_provider == "lab"
Streams::DnsProviders::Lab.new.hosts_file_snippet
rescue Redis::BaseError
""
end
end
end
@@ -2,7 +2,7 @@ module Admin
class YoutubeController < Admin::BaseController
def platform
if ENV["YOUTUBE_CLIENT_ID"].blank?
redirect_to admin_root_path, alert: "Configura YOUTUBE_CLIENT_ID e YOUTUBE_CLIENT_SECRET in .env"
redirect_to admin_root_path, alert: t("admin.flash.youtube_not_configured")
return
end
@@ -0,0 +1,35 @@
# frozen_string_literal: true
module Analytics
class EventsController < ActionController::API
def create
payload = parse_payload
result = Analytics::Ingest.new(events: payload, remote_ip: request.remote_ip).call
if result.rate_limited
return head :too_many_requests
end
render json: { accepted: result.accepted, rejected: result.rejected }, status: :accepted
end
private
def parse_payload
body = request.request_parameters
return body["events"] if body.is_a?(Hash) && body["events"].is_a?(Array)
return body if body.is_a?(Array)
raw = request.raw_post
return [] if raw.blank?
parsed = JSON.parse(raw)
return parsed["events"] if parsed.is_a?(Hash) && parsed["events"].is_a?(Array)
return parsed if parsed.is_a?(Array)
[]
rescue JSON::ParserError
[]
end
end
end
@@ -0,0 +1,21 @@
# frozen_string_literal: true
module Analytics
class SnapshotsController < ActionController::API
def create
result = Analytics::SnapshotIngest.new(
path: params[:path] || params[:page_path],
device: params[:device],
width: params[:width],
height: params[:height],
image: params[:image],
remote_ip: request.remote_ip
).call
return head :too_many_requests if result.rate_limited
return render json: { ok: false, error: result.error }, status: :unprocessable_content unless result.ok
render json: { ok: true, skipped: result.skipped }, status: :accepted
end
end
end
@@ -0,0 +1,60 @@
module Api
module V1
class AccountsController < ApplicationController
def show
render json: user_json(current_user)
end
def update
name = params[:name].to_s.strip
if name.blank?
return render json: { error: I18n.t("flash.accounts.name_required") }, status: :unprocessable_entity
end
if current_user.update(name: name)
render json: user_json(current_user)
else
render json: { error: current_user.errors.full_messages.join(", ") }, status: :unprocessable_entity
end
end
def password
result = Users::ChangePassword.call(
user: current_user,
current_password: params[:current_password],
password: params[:password],
password_confirmation: params[:password_confirmation]
)
unless result.ok?
return render json: { error: password_error_message(result.error) }, status: :unprocessable_entity
end
render json: { message: I18n.t("flash.accounts.password_updated") }
end
private
def user_json(user)
{
id: user.id,
email: user.email,
name: user.name,
role: user.role
}
end
def password_error_message(code)
case code
when :current_incorrect then I18n.t("flash.accounts.password_current_incorrect")
when :too_short then I18n.t("flash.accounts.password_too_short")
when :too_long then I18n.t("flash.accounts.password_too_long")
when :too_weak then I18n.t("flash.accounts.password_too_weak")
when :same_as_current then I18n.t("flash.accounts.password_same_as_current")
when :mismatch then I18n.t("flash.accounts.password_mismatch")
else I18n.t("flash.accounts.password_update_failed")
end
end
end
end
end
@@ -0,0 +1,13 @@
module Api
module V1
class AnnouncementsController < BaseController
skip_before_action :authenticate_request!
def index
platform = params[:platform].to_s.presence
announcements = AppAnnouncement.for_platform(platform).newest
render json: announcements.map { |item| item.as_api_json(platform: platform) }
end
end
end
end
@@ -1,7 +1,7 @@
module Api
module V1
class AuthController < ApplicationController
skip_before_action :authenticate_request!, only: %i[login refresh register]
skip_before_action :authenticate_request!, only: %i[login refresh register forgot_password]
def register
user = User.new(
@@ -22,18 +22,18 @@ module Api
if user&.authenticate(params[:password])
render json: token_response(user), status: :ok
else
render json: { error: "Invalid credentials" }, status: :unauthorized
render json: { error: I18n.t("flash.sessions.invalid_credentials") }, status: :unauthorized
end
end
def logout
render json: { message: "Logged out" }
render json: { message: I18n.t("flash.sessions.logged_out") }
end
def refresh
payload = JsonWebToken.decode(params[:refresh_token] || bearer_token)
user = User.find_by(id: payload&.dig(:user_id))
return render json: { error: "Invalid token" }, status: :unauthorized unless user
return render json: { error: I18n.t("flash.sessions.invalid_token") }, status: :unauthorized unless user
render json: token_response(user)
end
@@ -42,6 +42,13 @@ module Api
render json: user_json(current_user)
end
def forgot_password
Users::RequestPasswordReset.call(email: params[:email])
render json: {
message: I18n.t("flash.password_resets.email_sent")
}
end
private
def token_response(user)
@@ -2,6 +2,8 @@ module Api
module V1
class BaseController < ApplicationController
rescue_from Teams::EntitlementError, with: :render_entitlement_error
rescue_from Streams::IngestUnavailableError, with: :render_ingest_unavailable
rescue_from BrandingAttachments::CoverUploadError, with: :render_cover_upload_error
rescue_from Youtube::BroadcastService::Error, with: :render_youtube_error
private
@@ -20,6 +22,17 @@ module Api
billing_url: error.billing_url
}, status: :forbidden
end
def render_ingest_unavailable(error)
render json: {
error: error.message,
error_code: error.code
}, status: :service_unavailable
end
def render_cover_upload_error(error)
render json: { error: error.message, error_code: "cover_upload_invalid" }, status: :unprocessable_entity
end
end
end
end
@@ -1,6 +1,10 @@
module Api
module V1
class MatchesController < BaseController
include Api::UrlHelper
include Api::CoverJson
include BrandingAttachments
before_action :set_team, only: %i[index create]
before_action :set_match, only: %i[show update destroy]
@@ -8,11 +12,17 @@ module Api
matches = @team.matches
.includes(:team, :stream_sessions)
.order(Arel.sql("scheduled_at ASC NULLS LAST"), created_at: :desc)
.select(&:coach_hub_visible?)
render json: matches.map { |m| match_json(m) }
end
def create
match = @team.matches.create!(match_params)
attrs = match_params.to_h
attrs["sport_key"] = @team.sport_key
normalize_scoring_rules!(attrs)
match = @team.matches.create!(attrs)
attach_opponent_logo(match)
attach_match_cover(match)
render json: match_json(match), status: :created
end
@@ -21,7 +31,12 @@ module Api
end
def update
@match.update!(match_params)
attrs = match_params.to_h
normalize_scoring_rules!(attrs)
normalize_opponent_color!(attrs)
@match.update!(attrs)
attach_opponent_logo(@match)
attach_match_cover(@match)
render json: match_json(@match)
end
@@ -46,41 +61,86 @@ module Api
def set_match
team_ids = current_user.streamable_teams.map(&:id)
@match = Match.where(team_id: team_ids).find(params[:id])
@match = Match.includes(:team).where(team_id: team_ids).find(params[:id])
end
def match_params
params.require(:match).permit(
:opponent_name, :location, :scheduled_at, :sport, :sets_to_win,
:category, :phase, roster_numbers: [],
scoring_rules: %i[points_per_set points_deciding_set min_point_lead]
p = params.require(:match).permit(
:opponent_name, :location, :scheduled_at, :sport, :sport_key, :sets_to_win,
:category, :opponent_primary_color, :overlay_kind,
scoring_rules: {}
)
if p[:sport].present? && p[:sport_key].blank?
p[:sport_key] = p.delete(:sport)
end
p[:overlay_kind] = nil if p.key?(:overlay_kind) && p[:overlay_kind].blank?
p
end
def normalize_scoring_rules!(attrs)
return unless attrs.key?("scoring_rules")
rules = attrs["scoring_rules"]
attrs["scoring_rules"] = {} if rules.blank?
if attrs["sets_to_win"].present? && attrs["scoring_rules"].is_a?(Hash)
attrs["scoring_rules"]["sets_to_win"] ||= attrs["sets_to_win"]
end
end
def normalize_opponent_color!(attrs)
return unless attrs.key?("opponent_primary_color")
attrs["opponent_primary_color"] = normalize_hex_color(
attrs["opponent_primary_color"],
Match::DEFAULT_OPPONENT_COLOR
)
end
def attach_opponent_logo(match)
file = params[:opponent_logo_file]
match.opponent_logo_file.attach(file) if file.present?
end
def attach_match_cover(match)
attach_cover_image(match, entitlements: match.team.entitlements)
enqueue_cover_slate_generation(match)
end
def match_json(match, detail: false)
active = active_session_for(match)
team = match.team
{
id: match.id,
team_id: match.team_id,
team_name: match.team.name,
team_name: team.name,
opponent_name: match.opponent_name,
location: match.location,
scheduled_at: match.scheduled_at,
sport: match.sport,
sport: match.sport_key,
sport_key: match.sport_key,
sport_label: match.sport_label,
board_type: match.effective_board_type,
overlay_kind: match.overlay_kind,
effective_overlay_kind: match.effective_overlay_kind,
sets_to_win: match.sets_to_win,
scoring_rules: match.scoring_rules.presence,
roster_numbers: match.roster_numbers,
effective_scoring_rules: match.effective_scoring_rules,
category: match.category,
phase: match.phase,
home_primary_color: team.effective_primary_color,
home_secondary_color: team.effective_secondary_color,
home_logo_url: api_absolute_url(team.effective_logo_url),
opponent_primary_color: match.effective_opponent_primary_color,
opponent_logo_url: api_absolute_url(match.opponent_logo_url),
**match_cover_json(match),
active_session_id: active&.id,
active_session_status: active&.status
active_session_status: active&.status,
stream_completed: match.stream_completed?,
coach_hub_visible: match.coach_hub_visible?
}
end
def active_session_for(match)
match.stream_sessions
.order(created_at: :desc)
.find { |s| !%w[ended error].include?(s.status) }
match.active_stream_session
end
end
end
@@ -5,13 +5,18 @@ module Api
def update
authorize_manage!
@recording.update!(recording_update_attrs)
attrs = recording_update_attrs
privacy_changed = attrs.key?(:privacy_status) && attrs[:privacy_status] != @recording.privacy_status
@recording.update!(attrs)
Recordings::SyncYoutubePrivacyJob.perform_async(@recording.id) if privacy_changed && @recording.youtube_video_id.present?
render json: recording_json(@recording)
end
def destroy
authorize_manage!
Recordings::Delete.new(@recording).call
scope = params[:delete_scope].to_s
scope = "both" unless Recordings::Delete::SCOPES.include?(scope)
Recordings::Delete.new(@recording, scope: scope).call
head :no_content
end
@@ -96,7 +101,10 @@ module Api
youtube_video_id: recording.youtube_video_id,
youtube_watch_url: recording.youtube_watch_url,
youtube_publish_enabled: ent.premium_full? && ent.youtube_enabled?,
source_platform: recording.source_platform,
source_platform_label: recording.source_platform_label,
expires_at: recording.expires_at,
days_until_expiry: recording.days_until_expiry,
metadata: recording.metadata
}
end
@@ -0,0 +1,9 @@
module Api
module V1
class SportsController < BaseController
def index
render json: ::Sports::Catalog.as_api_list
end
end
end
end
@@ -34,11 +34,31 @@ module Api
render json: session_json(@session)
end
def audio_mute
muted = params.key?(:muted) ? params[:muted] : true
Sessions::SetAudioMute.new(@session, muted: muted).call
render json: session_json(@session)
end
def score
Scoring::SyncState.new(session: @session, payload: score_sync_params).call
render json: session_json(@session, detail: true)
end
def score_action
action = params[:score_action].presence
raise ArgumentError, "Azione mancante" if action.blank?
result = Scoring::ApplyAction.new(session: @session, action: action).call
render json: session_json(@session, detail: true).merge(
set_won: result.set_won,
match_won: result.match_won,
winner: result.winner&.to_s
)
rescue ArgumentError => e
render json: { error: e.message }, status: :unprocessable_entity
end
def events
events = @session.stream_events.recent.limit(100)
render json: events.map { |e| event_json(e) }
@@ -54,6 +74,7 @@ module Api
current_bitrate: params[:current_bitrate],
target_bitrate: params[:target_bitrate],
fps: params[:fps],
thermal_state: sanitized_thermal_state,
last_seen_at: Time.current
)
sync_publisher_when_streaming!(params[:fps].to_f)
@@ -100,13 +121,38 @@ module Api
end
def network_test
upload_mbps = params[:upload_mbps].to_f
preset = Sessions::SelectQuality.call(upload_mbps: upload_mbps)
@session.update!(
quality_preset: preset[:id],
target_bitrate: preset[:target_bitrate],
target_fps: preset[:target_fps]
)
@session.stream_events.create!(
event_type: "network_test",
metadata: params.permit(:download_mbps, :upload_mbps, :latency_ms, :network_type).to_h,
metadata: params.permit(:download_mbps, :upload_mbps, :latency_ms, :network_type)
.to_h.merge(
"quality_preset" => preset[:id],
"target_bitrate" => preset[:target_bitrate]
),
occurred_at: Time.current
)
ready = params[:upload_mbps].to_f >= (@session.target_bitrate / 1_000_000.0 * 0.8)
render json: { ready: ready, target_upload_mbps: @session.target_bitrate / 1_000_000.0 }
target_upload = preset[:target_bitrate] / 1_000_000.0
ready = upload_mbps >= (target_upload * Sessions::SelectQuality::UPLOAD_HEADROOM)
render json: {
ready: ready,
target_upload_mbps: target_upload,
quality_preset: preset[:id],
target_bitrate: preset[:target_bitrate],
target_fps: preset[:target_fps]
}
end
def min_quality
Sessions::SetMinQuality.new(@session, preset: params[:min_quality_preset]).call
render json: session_json(@session)
rescue ArgumentError => e
render json: { error: e.message }, status: :unprocessable_entity
end
def youtube_stats
@@ -128,6 +174,11 @@ module Api
.find(params[:id])
end
def sanitized_thermal_state
value = params[:thermal_state].to_s.downcase.presence
value if value.in?(%w[nominal fair serious critical])
end
def session_params
params.permit(:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps, :youtube_channel)
end
@@ -135,6 +186,7 @@ module Api
def score_sync_params
params.permit(
:home_sets, :away_sets, :home_points, :away_points, :current_set,
:clock_secs, :clock_running, :period, :home_score, :away_score, :overtime,
set_partials: %i[set home away]
)
end
@@ -147,6 +199,7 @@ module Api
platform: session.platform,
rtmp_ingest_url: session.rtmp_ingest_url,
hls_playback_url: session.hls_playback_url,
stream_node: session.stream_node&.slug,
watch_page_url: session.matchlivetv_platform? ? session.watch_page_url : nil,
share_url: session.share_url,
youtube_watch_url: session.youtube_watch_url,
@@ -154,9 +207,11 @@ module Api
youtube_ready: session.youtube_ready?,
privacy_status: session.privacy_status,
quality_preset: session.quality_preset,
min_quality_preset: session.min_quality_preset,
target_bitrate: session.target_bitrate,
started_at: session.started_at,
disconnection_count: session.disconnection_count
disconnection_count: session.disconnection_count,
audio_muted: session.audio_muted
}
if detail
data[:score] = session.score_state&.as_cable_payload
@@ -1,6 +1,9 @@
module Api
module V1
class TeamsController < BaseController
include Api::UrlHelper
include BrandingAttachments
def index
teams = current_user.streamable_teams
render json: teams.map { |t| team_json(t) }
@@ -18,10 +21,11 @@ module Api
}, status: :unprocessable_entity
end
club = Club.create!(name: team_params[:name], sport: team_params[:sport] || "volleyball",
sport_key = ::Sports::Catalog.normalize_key(team_params[:sport_key] || team_params[:sport] || "pallavolo")
club = Club.create!(name: team_params[:name], sport: sport_key,
primary_color: "#e53935", secondary_color: "#ffffff")
ClubMembership.create!(user: current_user, club: club, role: "owner")
team = club.teams.create!(name: team_params[:name], sport: club.sport)
team = club.teams.create!(name: team_params[:name], sport_key: sport_key)
Billing::AssignPlan.call(club: club, plan_slug: "free")
render json: team_json(team), status: :created
end
@@ -39,6 +43,7 @@ module Api
recs = team.recordings.not_deleted
.where(status: %w[ready processing])
.where("expires_at IS NULL OR expires_at > ?", Time.current)
.includes(stream_session: :match)
.order(recorded_at: :desc, created_at: :desc)
.limit(50)
@@ -48,6 +53,7 @@ module Api
def update
team = current_user.manageable_teams.find(params[:id])
team.update!(team_params)
attach_team_logo(team)
render json: team_json(team)
end
@@ -69,7 +75,23 @@ module Api
private
def team_params
params.require(:team).permit(:name, :sport, :logo_url)
p = params.require(:team).permit(:name, :sport, :sport_key, :logo_url, :primary_color, :secondary_color)
if p[:sport].present? && p[:sport_key].blank?
p[:sport_key] = p.delete(:sport)
end
p
if p[:primary_color].present?
p[:primary_color] = normalize_hex_color(p[:primary_color], p[:primary_color])
end
if p[:secondary_color].present?
p[:secondary_color] = normalize_hex_color(p[:secondary_color], p[:secondary_color])
end
p
end
def attach_team_logo(team)
file = params[:logo_file]
team.logo_file.attach(file) if file.present?
end
def team_json(team, detail: false)
@@ -79,8 +101,11 @@ module Api
data = {
id: team.id,
name: team.name,
sport: team.sport,
logo_url: team.effective_logo_url,
sport: team.sport_key,
sport_key: team.sport_key,
sport_label: team.sport_label,
board_type: team.effective_board_type,
logo_url: api_absolute_url(team.effective_logo_url),
primary_color: team.effective_primary_color,
secondary_color: team.effective_secondary_color,
club_id: team.club_id,
@@ -112,7 +137,8 @@ module Api
youtube_mode: ent.plan.youtube_mode,
staff_role: current_user.staff_role_for(team),
can_stream: current_user.can_stream_for?(team),
can_connect_youtube: team.club.owned_by?(current_user) && ent.premium_full? && ent.youtube_enabled?
can_connect_youtube: team.club.owned_by?(current_user) && ent.premium_full? && ent.youtube_enabled?,
custom_cover_enabled: ent.can_use_custom_cover?
}
if detail
data[:members] = team.user_teams.includes(:user).where.not(role: "owner").map do |ut|
@@ -151,7 +177,10 @@ module Api
youtube_video_id: recording.youtube_video_id,
youtube_watch_url: recording.youtube_watch_url,
youtube_publish_enabled: ent.premium_full? && ent.youtube_enabled?,
source_platform: recording.source_platform,
source_platform_label: recording.source_platform_label,
expires_at: recording.expires_at,
days_until_expiry: recording.days_until_expiry,
metadata: recording.metadata
}
end
@@ -1,17 +1,25 @@
class ApplicationController < ActionController::API
include ActionController::HttpAuthentication::Token::ControllerMethods
before_action :set_api_locale
before_action :authenticate_request!
attr_reader :current_user
private
def set_api_locale
explicit = LocaleResolver.normalize(request.headers["X-Locale"])
I18n.locale = explicit ||
LocaleResolver.from_accept_language(request.headers["Accept-Language"]) ||
I18n.default_locale
end
def authenticate_request!
token = bearer_token
payload = JsonWebToken.decode(token)
@current_user = User.find_by(id: payload[:user_id]) if payload
render json: { error: "Unauthorized" }, status: :unauthorized unless @current_user
render json: { error: I18n.t("flash.sessions.unauthorized") }, status: :unauthorized unless @current_user
end
def bearer_token
@@ -0,0 +1,16 @@
module Api
module CoverJson
extend ActiveSupport::Concern
private
def match_cover_json(match)
resolver = Streams::CoverResolver.new(match)
{
effective_cover_url: api_absolute_url(resolver.effective_cover_url),
cover_source: resolver.cover_source,
custom_cover_enabled: match.team.entitlements.can_use_custom_cover?
}
end
end
end
@@ -0,0 +1,16 @@
module Api
module UrlHelper
extend ActiveSupport::Concern
private
def api_absolute_url(path_or_url)
return nil if path_or_url.blank?
return path_or_url if path_or_url.match?(/\Ahttps?:\/\//i)
base = request.base_url
path = path_or_url.start_with?("/") ? path_or_url : "/#{path_or_url}"
"#{base}#{path}"
end
end
end
@@ -8,6 +8,48 @@ module BrandingAttachments
record.logo_file.attach(file) if file.present?
end
def attach_cover_image(record, entitlements:)
remove_flag = params[:remove_cover].to_s == "1" || params.dig(:match, :remove_cover).to_s == "1"
if remove_flag
record.cover_image.purge if record.cover_image.attached?
record.cover_slate.purge if record.cover_slate.attached?
return
end
file = params.dig(:branding, :cover_image) || params[:cover_image]
return if file.blank?
entitlements.assert_can_upload_cover!
validate_cover_file!(file)
record.cover_slate.purge if record.cover_slate.attached?
record.cover_image.attach(file)
end
def validate_cover_file!(file)
allowed_types = Coverable::COVER_IMAGE_TYPES
# Rileva MIME dal contenuto (magic bytes) ignorando il declared_type del client.
io = file.respond_to?(:tempfile) ? file.tempfile : file
io.rewind if io.respond_to?(:rewind)
detected = Marcel::MimeType.for(io, name: file.respond_to?(:original_filename) ? file.original_filename : nil)
io.rewind if io.respond_to?(:rewind)
unless detected.in?(allowed_types)
raise CoverUploadError, I18n.t("coverable.errors.invalid_type")
end
byte_size = file.respond_to?(:size) ? file.size : File.size(file.path)
if byte_size > Coverable::MAX_COVER_BYTES
raise CoverUploadError, I18n.t("coverable.errors.too_large")
end
end
class CoverUploadError < StandardError; end
def enqueue_cover_slate_generation(record)
GenerateCoverSlateJob.enqueue_for(record) if record.cover_image.attached?
end
def normalize_hex_color(value, fallback)
v = value.to_s.strip
v = "##{v}" if v.match?(/\A[0-9A-Fa-f]{6}\z/)
@@ -4,11 +4,20 @@ module MediamtxPlayback
private
def mediamtx_paths_index
@mediamtx_paths_index ||= Mediamtx::Client.new.list_paths.index_by { |i| i["name"] }
mediamtx_paths_index_for_url(MatchLiveTv.mediamtx_api_url)
end
def mediamtx_paths_index_for(session)
mediamtx_paths_index_for_url(session.mediamtx_api_base_url)
end
def mediamtx_paths_index_for_url(api_url)
@mediamtx_paths_by_origin ||= {}
@mediamtx_paths_by_origin[api_url] ||= Mediamtx::Client.new(base_url: api_url).list_paths.index_by { |i| i["name"] }
end
def mediamtx_path_info(session, path_name: mediamtx_playback_path_name(session))
mediamtx_paths_index[path_name]
mediamtx_paths_index_for(session)[path_name]
end
def mediamtx_playback_path_name(session)
@@ -28,7 +37,7 @@ module MediamtxPlayback
end
def mediamtx_publisher_online?(session)
info = mediamtx_paths_index[session.mediamtx_path_name]
info = mediamtx_paths_index_for(session)[session.mediamtx_path_name]
info && info["online"]
end
end
@@ -0,0 +1,140 @@
module Recordings
module ClubArchiveActions
extend ActiveSupport::Concern
include RecordingsArchiveHelper
included do
before_action :set_club
before_action :set_recording, only: %i[update destroy publish_youtube]
end
def index
load_archive
end
def update
if admin_expiry_update_requested?
apply_admin_expiry_update!
return
end
permitted = recording_update_params
attrs = {}
privacy = permitted[:privacy_status]
attrs[:privacy_status] = privacy if privacy.in?(Recording::PRIVACY_STATUSES)
title = permitted[:title]
attrs[:title] = title if title.present?
privacy_changed = attrs.key?(:privacy_status) && attrs[:privacy_status] != @recording.privacy_status
@recording.update!(attrs)
Recordings::SyncYoutubePrivacyJob.perform_async(@recording.id) if privacy_changed && @recording.youtube_video_id.present?
redirect_to archive_index_path, notice: "Replay aggiornato"
rescue ActiveRecord::RecordInvalid => e
redirect_to archive_index_path, alert: e.record.errors.full_messages.join(", ")
rescue Recordings::ExtendExpiry::Error => e
redirect_to archive_index_path, alert: e.message
end
def destroy
scope = delete_scope_param
Recordings::Delete.new(@recording, scope: scope).call
redirect_to archive_index_path, notice: delete_notice_for(scope)
end
def publish_youtube
ent = @recording.team.entitlements
unless ent.premium_full? && ent.youtube_enabled?
redirect_to archive_index_path, alert: "Republicazione YouTube con Premium Full"
return
end
Recordings::PublishToYoutubeJob.perform_async(@recording.id)
redirect_to archive_index_path, notice: "Pubblicazione su YouTube avviata"
end
private
def load_archive
archive = Recordings::ClubArchive.new(@club, params: params)
@stats = archive.stats
@recordings = archive.recordings
@teams = archive.teams
@filter_team_id = archive.filter_team_id
@filter_status = archive.filter_status
@archive_paths = recordings_archive_paths(@club, namespace: archive_namespace)
@archive_filter_params = archive.filter_params
end
def archive_index_path
archive_paths.index + archive_query_string
end
def archive_query_string
q = @archive_filter_params.presence
return "" if q.blank?
"?#{q.to_query}"
end
def archive_paths
@archive_paths ||= recordings_archive_paths(@club, namespace: archive_namespace)
end
def set_club
@club = Club.find(params[:club_id])
end
def set_recording
@recording = Recording.for_club(@club).not_deleted.find(params[:id])
authorize_recording_management!
end
def authorize_recording_management!
# overridden in Public::ClubRecordingsController
end
def archive_namespace
raise NotImplementedError
end
def recording_update_params
params.require(:recording).permit(:privacy_status, :title, :expires_at, :extend_days)
end
def delete_scope_param
value = params[:delete_scope].to_s
Recordings::Delete::SCOPES.include?(value) ? value : "both"
end
def delete_notice_for(scope)
case scope
when "site"
"Replay eliminato dal sito (YouTube invariato)"
when "youtube"
"Video rimosso da YouTube (resta disponibile sul sito)"
else
"Replay eliminato dal sito e da YouTube"
end
end
def admin_expiry_update_requested?
return false unless archive_namespace == :admin
permitted = recording_update_params
permitted[:extend_days].present? || permitted[:expires_at].present?
end
def apply_admin_expiry_update!
permitted = recording_update_params
extender = Recordings::ExtendExpiry.new(@recording)
if permitted[:extend_days].present?
extender.by_days(permitted[:extend_days])
redirect_to archive_index_path, notice: "Scadenza replay prolungata di #{permitted[:extend_days].to_i} giorni"
else
expires_at = Time.zone.parse(permitted[:expires_at].to_s)&.end_of_day
extender.to_time(expires_at)
redirect_to archive_index_path, notice: "Scadenza replay aggiornata"
end
end
end
end
+93 -21
View File
@@ -1,38 +1,110 @@
class HlsProxyController < ActionController::Base
# Proxy pubblico verso MediaMTX HLS (NPM inoltra tutto a Rails :3000).
# Proxy pubblico verso MediaMTX HLS.
# Dev: tutto passa da Rails (/hls/*).
# Prod: edge inoltra /hls/ a MediaMTX; il redirect cookie di MediaMTX punta a
# /live/match_{uuid}/… che deve essere gestito da #live_match (fallback).
def show
upstream_path = params[:path].to_s
proxy_mediamtx_path(params[:path].to_s)
end
def live_match
session_id = params[:session_id].to_s
return head :not_found unless session_id.match?(/\A[0-9a-f-]{36}\z/i)
segments = params[:segments].presence || "index.m3u8"
proxy_mediamtx_path("live/match_#{session_id}/#{segments}")
end
private
def proxy_mediamtx_path(upstream_path)
return head :not_found if upstream_path.blank?
upstream = "#{MatchLiveTv.mediamtx_hls_url}/#{upstream_path}"
origin = hls_origin_for(upstream_path)
upstream = "#{origin}/#{upstream_path}"
upstream = "#{upstream}?#{request.query_string}" if request.query_string.present?
cookie = request.headers["Cookie"].presence || "cookieCheck=1"
response = hls_conn.get(upstream) { |req| req.headers["Cookie"] = cookie }
if response.status == 302
location = response.headers["location"].to_s
follow_url = if location.start_with?("http")
location
else
"#{MatchLiveTv.mediamtx_hls_url.sub(%r{/$}, "")}#{location}"
end
set_cookie = response.headers["set-cookie"].to_s
cookie = set_cookie.presence || cookie
response = hls_conn.get(follow_url) { |req| req.headers["Cookie"] = cookie }
5.times do
response = hls_conn.get(upstream) { |req| req.headers["Cookie"] = cookie }
if response.status.in?([301, 302, 307, 308])
location = response.headers["location"].to_s
cookie = cookie_from_set_header(response.headers["set-cookie"]).presence || cookie
upstream = resolve_upstream_url(location, origin)
next
end
return render_proxied_response(response)
end
response.headers.each do |key, value|
next if %w[transfer-encoding connection].include?(key.downcase)
headers[key] = value
end
render body: response.body, status: response.status
head :bad_gateway
rescue Faraday::Error => e
Rails.logger.warn("[HlsProxy] #{upstream_path}: #{e.message}")
head :bad_gateway
end
private
def hls_origin_for(upstream_path)
session_id = upstream_path[/\Alive\/match_([0-9a-f-]{36})/i, 1]
if session_id
session = StreamSession.find_by(id: session_id)
node_hls = session&.stream_node&.internal_hls_url.presence
return node_hls.sub(%r{/$}, "") if node_hls.present?
end
MatchLiveTv.mediamtx_hls_url.sub(%r{/$}, "")
end
def resolve_upstream_url(location, origin)
return location if location.start_with?("http://", "https://")
base = origin.to_s.sub(%r{/$}, "")
location.start_with?("/") ? "#{base}#{location}" : "#{base}/#{location}"
end
def render_proxied_response(response)
body = response.body.to_s
content_type = response.headers["content-type"].to_s
if m3u8_playlist?(content_type, body)
public_hls_base = "#{request.base_url}/hls"
body = body.gsub(%r{(?<=["'(\s])/live/(match_[0-9a-f-]{36}/)}, "/hls/live/\\1")
body = body.gsub(%r{\A/live/(match_[0-9a-f-]{36}/)}, "/hls/live/\\1")
end
response.headers.each do |key, value|
next if %w[transfer-encoding connection content-length content-encoding].include?(key.downcase)
if key.downcase == "location"
headers[key] = rewrite_public_location(value)
else
headers[key] = value
end
end
render body: body, status: response.status, content_type: content_type.presence
end
def rewrite_public_location(location)
loc = location.to_s
if loc.start_with?("/live/match_")
return loc.sub(%r{\A/live/}, "/hls/")
end
if loc.include?("/live/match_")
return loc.gsub(%r{/(live/match_[0-9a-f-]{36}/)}, "/hls/live/\\1")
end
loc
end
def m3u8_playlist?(content_type, body)
return true if content_type.include?("mpegurl") || content_type.include?("m3u8")
body.lstrip.start_with?("#EXTM3U")
end
def cookie_from_set_header(set_cookie_header)
set_cookie_header.to_s[/cookieCheck=[^;]+/]
end
def hls_conn
@hls_conn ||= Faraday.new do |f|
@@ -0,0 +1,28 @@
module Ops
class HealthController < ActionController::API
before_action :authorize_deep_health!
def show
summary = Ops::HealthChecks.new.summary
status_code = case summary[:status]
when "ok" then :ok
when "degraded" then :service_unavailable
else :service_unavailable
end
render json: summary, status: status_code
end
private
def authorize_deep_health!
token = ENV["OPS_HEALTH_TOKEN"].presence
return if token.blank?
provided = request.headers["X-Ops-Token"].presence || params[:token].presence
return head :unauthorized if provided.blank?
head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(provided, token)
end
end
end
@@ -0,0 +1,39 @@
module Public
class AccountsController < WebBaseController
before_action :require_login!
def show
end
def update
name = params[:name].to_s.strip
if name.blank?
flash.now[:alert] = t("flash.accounts.name_required")
return render :show, status: :unprocessable_entity
end
if current_user.update(name: name)
redirect_to public_account_path, notice: t("flash.accounts.profile_updated")
else
flash.now[:alert] = current_user.errors.full_messages.to_sentence
render :show, status: :unprocessable_entity
end
end
def update_password
result = Users::ChangePassword.call(
user: current_user,
current_password: params[:current_password],
password: params[:password],
password_confirmation: params[:password_confirmation]
)
unless result.ok?
redirect_to public_account_path, alert: t("flash.accounts.password_#{result.error}")
return
end
redirect_to public_account_path, notice: t("flash.accounts.password_updated")
end
end
end
@@ -11,13 +11,17 @@ module Public
@club.assign_attributes(billing_profile_params)
if @club.save(context: :billing_profile)
if premium_checkout_return_params.present? && @club.billing_profile_complete?
redirect_to public_club_checkout_path(
@club,
plan: premium_checkout_return_params[:plan],
interval: premium_checkout_return_params[:interval]
), notice: "Dati salvati. Procedi con il pagamento."
if @club.active_billing_quote.present?
redirect_to public_club_billing_path(@club), notice: t("flash.club_billing.profile_updated")
else
redirect_to public_club_checkout_path(
@club,
plan: premium_checkout_return_params[:plan],
interval: premium_checkout_return_params[:interval]
), notice: t("flash.club_billing.profile_saved_proceed_payment")
end
else
redirect_to public_club_billing_path(@club), notice: "Dati di fatturazione aggiornati."
redirect_to public_club_billing_path(@club), notice: t("flash.club_billing.profile_updated")
end
else
flash.now[:alert] = @club.errors.full_messages.join(", ")
@@ -27,25 +31,48 @@ module Public
def cancel_subscription
unless MatchLiveTv.stripe_enabled?
redirect_to public_club_billing_path(@club), alert: "Stripe non configurato."
redirect_to public_club_billing_path(@club), alert: t("flash.club_billing.stripe_not_configured")
return
end
Billing::Stripe::CancelSubscription.call(club: @club)
sub = @club.subscription.reload
date = sub.current_period_end ? helpers.l_local(sub.current_period_end.to_date) : "fine periodo"
date = sub.current_period_end ? helpers.l_local(sub.current_period_end.to_date) : t("billing.subscription_status.end_of_period")
redirect_to public_club_billing_path(@club),
notice: "Abbonamento disdetto. Resta attivo fino al #{date}; da quel giorno passerai al piano Free."
notice: t("flash.club_billing.subscription_canceled", date: date)
rescue ArgumentError, RuntimeError => e
redirect_to public_club_billing_path(@club), alert: e.message
rescue ::Stripe::StripeError => e
redirect_to public_club_billing_path(@club), alert: "Errore Stripe: #{e.message}"
redirect_to public_club_billing_path(@club), alert: t("flash.club_billing.stripe_error", message: e.message)
end
def request_bank_transfer
if @club.subscription&.admin_comped?
redirect_to public_club_billing_path(@club), alert: t("flash.clubs.comped_change_denied")
return
end
unless @club.billing_profile_complete?
redirect_to public_club_billing_profile_path(@club, plan: params[:plan], interval: params[:interval]),
alert: t("flash.clubs.complete_billing_first")
return
end
Billing::RequestBankTransfer.call(
club: @club,
user: current_user,
plan_slug: params[:plan],
interval: params[:interval]
)
redirect_to public_club_billing_path(@club), notice: t("flash.club_billing.bank_transfer_requested")
rescue Billing::RequestBankTransfer::Error, ArgumentError => e
redirect_to public_club_billing_path(@club), alert: e.message
end
def download_invoice
invoice = @club.billing_invoices.find(params[:invoice_id])
unless invoice.pdf.attached?
redirect_to public_club_billing_path(@club), alert: "PDF fattura non disponibile."
redirect_to public_club_billing_path(@club), alert: t("flash.club_billing.invoice_pdf_unavailable")
return
end
@@ -14,7 +14,7 @@ module Public
@selected_team = @schedulable_teams.find { |t| t.id.to_s == params[:team_id].to_s }
unless @selected_team
@match = default_match_for_form
flash.now[:alert] = "Seleziona una squadra valida."
flash.now[:alert] = t("flash.club_matches.invalid_team")
return render :new, status: :unprocessable_entity
end
@@ -22,7 +22,7 @@ module Public
match_params.merge(sport: @selected_team.sport, sets_to_win: 3)
)
redirect_to public_live_index_path(club_id: @club.id),
notice: "Partita programmata: #{@selected_team.name} vs #{@match.opponent_name}."
notice: t("flash.club_matches.scheduled", team: @selected_team.name, opponent: @match.opponent_name)
rescue ActiveRecord::RecordInvalid => e
@match = @selected_team.matches.build(match_params)
flash.now[:alert] = e.record.errors.full_messages.join(", ")
@@ -44,7 +44,7 @@ module Public
return if @schedulable_teams.any?
redirect_to public_live_index_path(club_id: @club.id),
alert: "Non hai permessi per programmare partite in questa società."
alert: t("flash.club_matches.no_schedulable_teams")
end
def resolve_selected_team
@@ -1,55 +1,28 @@
module Public
class ClubRecordingsController < WebBaseController
include Recordings::ClubArchiveActions
helper RecordingsArchiveHelper
before_action :require_login!
before_action :set_club
before_action -> { require_club_owner!(@club) }
before_action :set_recording, only: %i[update destroy publish_youtube]
def index
@stats = Recordings::ClubStats.new(@club).call
@recordings = Recording.for_club(@club)
.not_deleted
.includes(stream_session: { match: :team })
.order(recorded_at: :desc, created_at: :desc)
@teams = @club.teams.order(:name)
end
def update
privacy = params.require(:recording).permit(:privacy_status, :title)[:privacy_status]
attrs = {}
attrs[:privacy_status] = privacy if privacy.in?(Recording::PRIVACY_STATUSES)
title = params.dig(:recording, :title)
attrs[:title] = title if title.present?
@recording.update!(attrs)
redirect_to public_club_recordings_path(@club), notice: "Replay aggiornato"
rescue ActiveRecord::RecordInvalid => e
redirect_to public_club_recordings_path(@club), alert: e.record.errors.full_messages.join(", ")
end
def destroy
Recordings::Delete.new(@recording).call
redirect_to public_club_recordings_path(@club), notice: "Replay eliminato"
end
def publish_youtube
ent = @recording.team.entitlements
unless ent.premium_full? && ent.youtube_enabled?
redirect_to public_club_recordings_path(@club), alert: "Republicazione YouTube con Premium Full"
return
end
Recordings::PublishToYoutubeJob.perform_async(@recording.id)
redirect_to public_club_recordings_path(@club), notice: "Pubblicazione su YouTube avviata"
end
before_action :require_club_recording_manager!
private
def set_club
@club = current_user.owned_clubs.find(params[:club_id])
def archive_namespace
:public
end
def set_recording
@recording = Recording.for_club(@club).not_deleted.find(params[:id])
def authorize_recording_management!
return if Recordings::AccessPolicy.new(@recording, viewer: current_user).manage_allowed?
raise ActiveRecord::RecordNotFound
end
def require_club_recording_manager!
return if current_user.owned_clubs.exists?(id: @club.id)
return if current_user.manageable_teams.joins(:club).exists?(clubs: { id: @club.id })
redirect_to public_clubs_path, alert: t("flash.club_recordings.not_authorized")
end
end
end
@@ -12,7 +12,7 @@ module Public
def create
if current_user.owned_clubs.exists?
redirect_to public_club_path(current_user.primary_club), alert: "Hai già registrato una società"
redirect_to public_club_path(current_user.primary_club), alert: t("flash.clubs.already_registered")
return
end
@@ -22,26 +22,31 @@ module Public
ClubMembership.create!(user: current_user, club: club, role: "owner")
first_team_name = params.dig(:first_team, :name).presence || "Prima squadra"
club.teams.create!(
first_team_name = params.dig(:first_team, :name).presence || t("club.new.default_first_team_name")
first_team = club.teams.create!(
name: first_team_name,
sport: club.sport
)
plan = params[:plan].presence_in(%w[free premium_light premium_full]) || "free"
Billing::AssignPlan.call(club: club, plan_slug: plan)
desired_plan = params[:plan].presence_in(%w[free premium_light premium_full]) || "free"
Billing::AssignPlan.call(club: club, plan_slug: "free")
Teams::StaffAssignment.designate_club_owner!(team: first_team, user: current_user)
if plan.in?(%w[premium_light premium_full]) && MatchLiveTv.stripe_enabled?
unless club.billing_profile_complete?
redirect_to public_club_billing_profile_path(club, plan: plan, interval: checkout_interval_param),
alert: "Completa i dati di fatturazione prima di attivare un piano premium."
return
if desired_plan.in?(%w[premium_light premium_full])
interval = Billing::Stripe::PriceCatalog::DEFAULT_INTERVAL
if MatchLiveTv.stripe_enabled?
unless club.billing_profile_complete?
redirect_to public_club_billing_profile_path(club, plan: desired_plan, interval: interval),
alert: t("flash.clubs.complete_billing_first")
return
end
redirect_to public_club_checkout_path(club, plan: desired_plan, interval: interval)
else
redirect_to public_club_billing_path(club), notice: t("flash.clubs.created_complete_subscription")
end
interval = checkout_interval_param
redirect_to public_club_checkout_path(club, plan: plan, interval: interval)
else
redirect_to public_club_path(club), notice: "Società e prima squadra create."
redirect_to public_club_path(club), notice: t("flash.clubs.created")
end
rescue ActiveRecord::RecordInvalid => e
flash.now[:alert] = e.record.errors.full_messages.join(", ")
@@ -59,17 +64,26 @@ module Public
def edit
require_club_owner!(@club)
@entitlements = @club.teams.first&.entitlements
end
def update
require_club_owner!(@club)
@entitlements = @club.teams.first!.entitlements
@club.assign_attributes(club_params)
attach_branding_logo(@club)
attach_cover_image(@club, entitlements: @entitlements)
@club.save!
redirect_to public_club_path(@club), notice: "Società aggiornata."
enqueue_cover_slate_generation(@club)
redirect_to public_club_path(@club), notice: t("flash.clubs.updated")
rescue ActiveRecord::RecordInvalid => e
@entitlements = @club.teams.first&.entitlements
flash.now[:alert] = e.record.errors.full_messages.join(", ")
render :edit, status: :unprocessable_entity
rescue Teams::EntitlementError, BrandingAttachments::CoverUploadError => e
@entitlements = @club.teams.first&.entitlements
flash.now[:alert] = e.message
render :edit, status: :unprocessable_entity
end
def billing
@@ -82,18 +96,26 @@ module Public
@entitlements = @team.entitlements
@plans = Plan.ordered
@payments = @club.billing_payments.recent.includes(:invoice).limit(50)
@quote = @club.active_billing_quote
@pending_transfer = @club.billing_transfer_orders.awaiting_payment.first
end
def checkout
require_club_owner!(@club)
if @club.subscription&.admin_comped?
redirect_to public_club_billing_path(@club),
alert: "Il piano è un abbonamento omaggio gestito da Match Live TV. Per passare a un abbonamento a pagamento contatta il supporto."
alert: t("flash.clubs.comped_change_denied")
return
end
if @club.active_billing_quote.present?
redirect_to public_club_billing_path(@club),
alert: t("flash.club_billing.quote_checkout_denied")
return
end
unless MatchLiveTv.stripe_enabled?
redirect_to public_club_billing_path(@club), alert: "Pagamenti non ancora configurati sul server"
redirect_to public_club_billing_path(@club), alert: t("flash.clubs.stripe_not_configured")
return
end
@@ -105,15 +127,15 @@ module Public
if sub&.stripe_subscription_id.present? && sub.active? &&
sub.plan.slug == plan_slug && sub.billing_interval == interval && !sub.plan_change_pending?
redirect_to public_club_billing_path(@club),
notice: "Sei già su #{target_plan.name} (#{Billing::Stripe::PriceCatalog.label(plan_slug: plan_slug, interval: interval)})."
notice: t("flash.clubs.already_on_plan", plan: target_plan.name, price: Billing::Stripe::PriceCatalog.label(plan_slug: plan_slug, interval: interval))
return
end
if sub&.plan_change_pending? && sub.pending_plan&.slug == plan_slug &&
sub.pending_billing_interval == interval
when_label = sub.current_period_end.present? ? I18n.l(sub.current_period_end, format: :long) : "il prossimo rinnovo"
when_label = sub.current_period_end.present? ? I18n.l(sub.current_period_end, format: :long) : t("billing.messages.next_renewal")
redirect_to public_club_billing_path(@club),
notice: "Il passaggio a #{target_plan.name} è già programmato dal #{when_label}."
notice: t("flash.clubs.change_already_scheduled", plan: target_plan.name, date: when_label)
return
end
@@ -134,7 +156,7 @@ module Public
redirect_to public_club_billing_path(@club), alert: e.message
rescue ::Stripe::StripeError => e
Rails.logger.warn("[Stripe checkout] #{e.message}")
redirect_to public_club_billing_path(@club), alert: "Errore Stripe: #{e.message}"
redirect_to public_club_billing_path(@club), alert: t("flash.clubs.stripe_error", message: e.message)
end
def youtube_connect
@@ -143,7 +165,7 @@ module Public
entitlements.assert_can_connect_youtube!
if ENV["YOUTUBE_CLIENT_ID"].blank?
redirect_to public_club_path(@club), alert: "YouTube OAuth non configurato sul server"
redirect_to public_club_path(@club), alert: t("flash.clubs.youtube_oauth_not_configured")
return
end
@@ -161,13 +183,13 @@ module Public
def youtube_disconnect
require_club_owner!(@club)
@club.youtube_credential&.destroy!
redirect_to public_club_path(@club), notice: "Canale YouTube della società scollegato"
redirect_to public_club_path(@club), notice: t("flash.clubs.youtube_disconnected")
end
def portal
require_club_owner!(@club)
unless MatchLiveTv.stripe_enabled?
redirect_to public_club_billing_path(@club), alert: "Portale pagamenti non configurato"
redirect_to public_club_billing_path(@club), alert: t("flash.clubs.portal_not_configured")
return
end
@@ -190,6 +212,7 @@ module Public
)
p[:primary_color] = normalize_hex_color(p[:primary_color], "#e53935")
p[:secondary_color] = normalize_hex_color(p[:secondary_color], "#ffffff")
p[:sport] = Sports::Catalog.normalize_key(p[:sport]) if p[:sport].present?
p
end
@@ -203,7 +226,7 @@ module Public
@club,
plan: plan_slug,
interval: params[:interval].presence
), alert: "Completa i dati di fatturazione prima di attivare un piano premium."
), alert: t("flash.clubs.complete_billing_first")
end
def checkout_interval_param
@@ -235,10 +258,10 @@ module Public
def apply_checkout_flash!
case params[:checkout]
when "success"
plan_name = @subscription&.plan&.name || "premium"
flash.now[:notice] = "Pagamento completato! Piano attivo: #{plan_name}."
plan_name = @subscription&.plan&.name || "Premium"
flash.now[:notice] = t("flash.clubs.checkout_success", plan: plan_name)
when "canceled"
flash.now[:alert] = "Pagamento annullato. Nessun addebito è stato effettuato."
flash.now[:alert] = t("flash.clubs.checkout_canceled")
end
end
end
@@ -0,0 +1,46 @@
module Public
class ContactsController < WebBaseController
def show
@inquiry = empty_inquiry
end
def create
result = Contacts::SubmitInquiry.call(params: inquiry_params, ip: request.remote_ip)
if result.ok
redirect_to public_contatti_path, notice: t("flash.contacts.sent")
return
end
@inquiry = inquiry_params
flash.now[:alert] = t("flash.contacts.#{result.error}")
render :show, status: result.error == :throttled ? :too_many_requests : :unprocessable_entity
end
private
def inquiry_params
{
name: params[:name].to_s,
email: params[:email].to_s,
club_name: params[:club_name].to_s,
topic: params[:topic].to_s,
message: params[:message].to_s,
website: params[:website].to_s,
accept_privacy: params[:accept_privacy].to_s
}
end
def empty_inquiry
{
name: "",
email: "",
club_name: "",
topic: "commercial",
message: "",
website: "",
accept_privacy: ""
}
end
end
end
@@ -4,24 +4,24 @@ module Public
@token = params[:token]
@invitation = TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(@token.to_s))
unless @invitation
redirect_to public_pricing_path, alert: "Invito non valido o scaduto"
redirect_to public_pricing_path, alert: t("flash.invitations.invalid_or_expired")
end
end
def accept
invitation = TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(params[:token].to_s))
return redirect_to public_pricing_path, alert: "Invito non valido" unless invitation
return redirect_to public_pricing_path, alert: t("flash.invitations.invalid") unless invitation
if logged_in?
if current_user.email.downcase != invitation.email.downcase
redirect_to public_pricing_path, alert: "Questo invito è per #{invitation.email}"
redirect_to public_pricing_path, alert: t("flash.invitations.wrong_email", email: invitation.email)
return
end
invitation.accept!(current_user)
redirect_to public_team_details_path(invitation.team), notice: "Sei entrato nella squadra!"
redirect_to public_team_details_path(invitation.team), notice: t("flash.invitations.joined_team")
else
session[:pending_invite_token] = params[:token]
redirect_to public_signup_path, notice: "Registrati con #{invitation.email} per accettare l'invito"
redirect_to public_signup_path, notice: t("flash.invitations.signup_to_accept", email: invitation.email)
end
end
end
@@ -51,6 +51,7 @@ module Public
@session.status.in?(%w[connecting live reconnecting paused])
)
@publisher_online = !@stream_closed && mediamtx_publisher_online?(@session)
@cover_poster_url = Streams::CoverResolver.new(@match).effective_cover_url
end
def status
@@ -0,0 +1,9 @@
module Public
class LocalesController < SiteBaseController
def update
locale = LocaleResolver.persist!(cookies, params[:locale])
I18n.locale = locale
redirect_back fallback_location: root_path
end
end
end
@@ -25,7 +25,7 @@ module Public
match_params.merge(sport: @team.sport, sets_to_win: match_params[:sets_to_win].presence || 3)
)
redirect_to public_team_matches_path(@team),
notice: "Partita programmata: #{@match.opponent_name}. Avvia lo streaming dallapp quando sei in palestra."
notice: t("flash.matches.scheduled", opponent: @match.opponent_name)
rescue ActiveRecord::RecordInvalid => e
@match = @team.matches.build(match_params)
flash.now[:alert] = e.record.errors.full_messages.join(", ")
@@ -37,7 +37,7 @@ module Public
def update
@match.update!(match_params)
redirect_to public_team_matches_path(@team), notice: "Partita aggiornata."
redirect_to public_team_matches_path(@team), notice: t("flash.matches.updated")
rescue ActiveRecord::RecordInvalid => e
flash.now[:alert] = e.record.errors.full_messages.join(", ")
render :edit, status: :unprocessable_entity
@@ -47,12 +47,12 @@ module Public
active = active_session_for(@match)
if active&.resumable?
redirect_to public_team_matches_path(@team),
alert: "Chiudi la diretta prima di eliminare questa partita."
alert: t("flash.matches.close_live_before_delete")
return
end
@match.destroy!
redirect_to public_team_matches_path(@team), notice: "Partita eliminata."
redirect_to public_team_matches_path(@team), notice: t("flash.matches.deleted")
end
private
@@ -66,7 +66,7 @@ module Public
return if current_user.can_schedule_for?(@team)
redirect_to public_team_details_path(@team),
alert: "Solo un responsabile trasmissione o il titolare della società possono programmare le partite."
alert: t("flash.matches.schedule_denied")
end
def set_match
@@ -94,9 +94,7 @@ module Public
end
def active_session_for(match)
match.stream_sessions
.order(created_at: :desc)
.find { |s| !%w[ended error].include?(s.status) }
match.active_stream_session
end
end
end
@@ -11,17 +11,8 @@ module Public
load_club_billing_context_for_pricing
end
private
def load_club_billing_context_for_pricing
return unless logged_in?
@club = current_user.primary_club
return unless @club
@subscription = @club.subscription
@team = @club.teams.order(:name).first
@entitlements = @team&.entitlements
def support
@app_store_review_chrome = true
end
def privacy
@@ -38,5 +29,18 @@ module Public
def pallavolo
end
private
def load_club_billing_context_for_pricing
return unless logged_in?
@club = current_user.primary_club
return unless @club
@subscription = @club.subscription
@team = @club.teams.order(:name).first
@entitlements = @team&.entitlements
end
end
end
@@ -4,21 +4,17 @@ module Public
end
def create
user = User.find_by(email: params[:email]&.downcase&.strip)
if user
token = user.generate_password_reset!
UserMailer.password_reset(user, token).deliver_now
end
Users::RequestPasswordReset.call(email: params[:email])
redirect_to public_login_path,
notice: "Se l'email è registrata, riceverai a breve un link per reimpostare la password."
notice: t("flash.password_resets.email_sent")
end
def edit
@user = User.find_by_password_reset_token(params[:token])
if @user.nil? || @user.password_reset_expired?
redirect_to new_public_password_reset_path,
alert: "Link non valido o scaduto. Richiedi un nuovo reset password."
redirect_to public_password_forgot_path,
alert: t("flash.password_resets.invalid_or_expired_link")
return
end
@token = params[:token]
@@ -27,26 +23,32 @@ module Public
def update
@user = User.find_by_password_reset_token(params[:token])
if @user.nil? || @user.password_reset_expired?
redirect_to new_public_password_reset_path,
alert: "Link non valido o scaduto. Richiedi un nuovo reset password."
redirect_to public_password_forgot_path,
alert: t("flash.password_resets.invalid_or_expired_link")
return
end
if params[:password].blank? || params[:password].length < 8
flash.now[:alert] = "La password deve avere almeno 8 caratteri"
if params[:password] != params[:password_confirmation]
flash.now[:alert] = t("flash.password_resets.password_mismatch")
@token = params[:token]
return render :edit, status: :unprocessable_entity
end
if params[:password] != params[:password_confirmation]
flash.now[:alert] = "Le password non coincidono"
if (code = PasswordComplexity.violation(params[:password]))
flash.now[:alert] = t("flash.password_resets.password_#{code == :blank ? :too_short : code}")
@token = params[:token]
return render :edit, status: :unprocessable_entity
end
if PasswordComplexity.same_as_current?(@user, params[:password])
flash.now[:alert] = t("flash.password_resets.password_same_as_current")
@token = params[:token]
return render :edit, status: :unprocessable_entity
end
@user.update!(password: params[:password])
@user.clear_password_reset!
redirect_to public_login_path, notice: "Password aggiornata. Ora puoi accedere."
redirect_to public_login_path, notice: t("flash.password_resets.password_updated")
end
end
end
@@ -1,26 +1,30 @@
module Public
class RegiaController < SiteBaseController
include Public::LiveHelper
include Public::RegiaHelper
include MediamtxPlayback
layout "regia"
protect_from_forgery with: :null_session
skip_before_action :verify_authenticity_token, only: %i[score pause stop]
skip_before_action :verify_authenticity_token, only: %i[score pause resume stop audio_mute min_quality]
before_action :set_session_from_token
def show
@match = @session.match
@team = @match.team
@score = @session.score_state || @session.create_score_state!(
home_sets: 0, away_sets: 0, home_points: 0, away_points: 0, current_set: 1, set_partials: []
)
@score = @session.score_state || Scoring::Engine.ensure_score_for(@session)
@rules = Scoring::Rules.from_match(@match)
@stream_closed = @session.terminal?
@on_air = !@stream_closed && mediamtx_stream_playable?(@session)
@share_url = request.original_url
@share_title = "Regia — #{@team.name} vs #{@match.opponent_name}"
@on_air = !@stream_closed && (
mediamtx_stream_playable?(@session) ||
@session.status.in?(%w[connecting live reconnecting paused])
)
@regia_share_url = request.original_url
@regia_share_title = "Regia — #{@team.name} vs #{@match.opponent_name}"
@live_share_url = @session.share_url
@live_share_title = "#{@team.name} vs #{@match.opponent_name} — Diretta Match Live TV"
end
def status
@@ -30,6 +34,7 @@ module Public
def score
cmd = params[:cmd].presence || params[:score_action].presence
result = Scoring::ApplyAction.new(session: @session, action: cmd).call
@session.reload
render json: status_payload.merge(
set_won: result.set_won,
match_won: result.match_won,
@@ -44,37 +49,66 @@ module Public
render json: status_payload
end
def resume
Sessions::Resume.new(@session).call
render json: status_payload
rescue Teams::EntitlementError => e
render json: status_payload.merge(error: e.message), status: :unprocessable_entity
end
def stop
Sessions::Stop.new(@session).call
render json: status_payload
end
def audio_mute
muted = params.key?(:muted) ? params[:muted] : !@session.audio_muted
Sessions::SetAudioMute.new(@session, muted: muted).call
render json: status_payload
end
def min_quality
Sessions::SetMinQuality.new(@session, preset: params[:min_quality_preset]).call
render json: status_payload
rescue ArgumentError => e
render json: status_payload.merge(error: e.message), status: :unprocessable_entity
end
private
def camera_telemetry_payload(publisher_online)
cam = @session.device_states.find_by(device_role: "camera")
(cam&.as_regia_payload || {}).merge(publisher_online: publisher_online)
end
def set_session_from_token
@session = Sessions::RegiaAccess.find_session_by_token(params[:token])
return if @session
render file: Rails.root.join("public/404.html"), status: :not_found, layout: false
throw :abort
head :not_found
end
def status_payload
score = @session.score_state
closed = @session.terminal?
playable = !closed && mediamtx_stream_playable?(@session)
publisher_online = !closed && mediamtx_publisher_online?(@session)
{
status: @session.status,
paused: @session.paused?,
audio_muted: @session.audio_muted,
stream_closed: closed,
live: !closed && (@session.live? || @session.paused? || mediamtx_publisher_online?(@session)),
on_air: playable,
publisher_online: !closed && mediamtx_publisher_online?(@session),
live: !closed && (@session.live? || @session.paused? || publisher_online),
on_air: playable || (!closed && @session.status.in?(%w[connecting live reconnecting paused])),
publisher_online: publisher_online,
ended_at: @session.ended_at,
home_name: @session.match.team.name,
away_name: @session.match.opponent_name,
points_target: Scoring::Rules.from_match(@session.match).points_target(score&.current_set || 1),
score: score&.as_cable_payload
points_target: Scoring::Rules.points_target_for_match(@session.match, current_set: score&.current_set || 1),
score: score&.as_cable_payload,
min_quality_preset: @session.min_quality_preset,
min_quality_options: Sessions::SelectQuality.allowed_min_ids(@session.quality_preset),
telemetry: camera_telemetry_payload(publisher_online)
}
end
end
@@ -8,7 +8,7 @@ module Public
def create
unless params[:accept_terms] == "1"
@user = User.new(user_params.merge(role: "coach"))
flash.now[:alert] = "Devi accettare linformativa privacy e i termini di servizio per registrarti."
flash.now[:alert] = t("flash.registrations.accept_terms_required")
return render :new, status: :unprocessable_entity
end
@@ -20,10 +20,10 @@ module Public
invitation = TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(token))
if invitation && invitation.email.downcase == @user.email.downcase
invitation.accept!(@user)
return redirect_to public_team_details_path(invitation.team), notice: "Benvenuto nella squadra!"
return redirect_to public_team_details_path(invitation.team), notice: t("flash.registrations.welcome_to_team")
end
end
redirect_to public_new_club_path, notice: "Account creato. Ora registra la tua società."
redirect_to public_new_club_path, notice: t("flash.registrations.account_created")
else
flash.now[:alert] = @user.errors.full_messages.join(", ")
render :new, status: :unprocessable_entity
@@ -1,5 +1,7 @@
module Public
class ReplayController < SiteBaseController
include Public::LiveHelper
layout "marketing_live"
before_action :set_session_and_recording, only: %i[show stream thumbnail download]
@@ -40,7 +42,7 @@ module Public
def download
unless download_allowed?
redirect_to public_replay_path(@session), alert: "Download non disponibile"
redirect_to public_replay_path(@session), alert: t("flash.replay.download_unavailable")
return
end
@@ -50,6 +52,15 @@ module Public
private
def serve_video(disposition:)
expires_in = disposition == "attachment" ? MatchLiveTv.replay_download_url_ttl_seconds : MatchLiveTv.replay_presigned_url_ttl_seconds
redirect_url = Recordings::PublicMediaUrl.new(
key: @recording.storage_key,
expires_in: expires_in,
disposition: disposition,
filename: disposition == "attachment" ? download_filename : nil
).call
return redirect_to redirect_url, allow_other_host: true if redirect_url.present?
Recordings::ServeFromStorage.new(
key: @recording.storage_key,
content_type: "video/mp4",
@@ -64,6 +75,9 @@ module Public
key = @recording.thumbnail_storage_key
return head :not_found if key.blank?
redirect_url = Recordings::PublicMediaUrl.new(key: key, expires_in: MatchLiveTv.replay_presigned_url_ttl_seconds).call
return redirect_to redirect_url, allow_other_host: true if redirect_url.present?
Recordings::ServeFromStorage.new(
key: key,
content_type: "image/jpeg",
@@ -91,13 +105,13 @@ module Public
@recording = Recording.not_deleted.find_by(stream_session: @session)
return if @recording
redirect_to public_live_path(@session), alert: "Replay non disponibile"
redirect_to public_live_path(@session), alert: t("flash.replay.not_available")
end
def authorize_replay_access!
return if Recordings::AccessPolicy.new(@recording, viewer: current_user).allowed?
redirect_to public_live_index_path, alert: "Replay non disponibile"
redirect_to public_live_index_path, alert: t("flash.replay.not_available")
end
end
end
@@ -1,5 +1,9 @@
module Public
class SessionsController < WebBaseController
# Logout must succeed even with a stale CSRF token (old tab after deploy /
# session rotate). SameSite=Lax already blocks cross-site cookie POSTs.
skip_before_action :verify_authenticity_token, only: :destroy
def new
if logged_in? && current_user.primary_club
redirect_to public_club_path(current_user.primary_club)
@@ -17,16 +21,16 @@ module Public
else
public_new_club_path
end
redirect_to dest, notice: "Bentornato!"
redirect_to dest, notice: t("flash.sessions.welcome_back")
else
flash.now[:alert] = "Email o password non validi"
flash.now[:alert] = t("flash.sessions.invalid_credentials")
render :new, status: :unauthorized
end
end
def destroy
reset_session
redirect_to public_pricing_path, notice: "Disconnesso"
redirect_to public_pricing_path, notice: t("flash.sessions.logged_out")
end
end
end
@@ -5,14 +5,31 @@ module Public
include ::SeoHelper
include ::LegalHelper
helper ApplicationHelper
helper_method :current_user, :logged_in?
helper_method :current_user, :logged_in?, :current_locale, :language_options, :current_language_option
protect_from_forgery with: :exception
before_action :set_site_locale
before_action :load_site_announcements
private
def set_site_locale
I18n.locale = :it
I18n.locale = LocaleResolver.resolve(
cookie_jar: request.cookie_jar,
accept_language: request.get_header("HTTP_ACCEPT_LANGUAGE")
)
end
def current_locale
I18n.locale
end
def language_options
LocaleResolver.language_options
end
def current_language_option
LocaleResolver.current_language_option
end
def current_user
@@ -24,5 +41,27 @@ module Public
def logged_in?
current_user.present?
end
PRIVATE_WEB_CONTROLLERS = %w[
accounts clubs teams club_recordings club_billing
club_matches matches team_roster_members
].freeze
def load_site_announcements
@site_announcements = if skip_site_announcements?
AppAnnouncement.none
else
channel = private_web_area? ? :web_private : :web_public
AppAnnouncement.for_channel(channel).newest
end
end
def skip_site_announcements?
controller_name == "regia"
end
def private_web_area?
PRIVATE_WEB_CONTROLLERS.include?(controller_name)
end
end
end
@@ -8,12 +8,23 @@ module Public
{ loc: "#{base}/prezzi", changefreq: "monthly", priority: "0.9" },
{ loc: "#{base}/pallavolo-giovanile", changefreq: "monthly", priority: "0.85" },
{ loc: "#{base}/faq", changefreq: "monthly", priority: "0.8" },
{ loc: "#{base}/contatti", changefreq: "monthly", priority: "0.6" },
{ loc: "#{base}/live", changefreq: "hourly", priority: "0.85" },
{ loc: "#{base}/squadre", changefreq: "daily", priority: "0.85" },
{ loc: "#{base}/privacy", changefreq: "yearly", priority: "0.3" },
{ loc: "#{base}/support", changefreq: "yearly", priority: "0.3" },
{ loc: "#{base}/cookie", changefreq: "yearly", priority: "0.3" },
{ loc: "#{base}/termini", changefreq: "yearly", priority: "0.3" }
]
Team.find_each do |team|
@entries << {
loc: "#{base}/squadre/#{team.slug}",
changefreq: "daily",
priority: "0.7"
}
end
respond_to do |format|
format.xml { render layout: false }
end
@@ -0,0 +1,64 @@
module Public
class TeamPagesController < SiteBaseController
include Public::LiveHelper
layout "marketing_live"
def index
@query = params[:q].to_s.strip
@sport = params[:sport].to_s.strip.presence
@live_filter = params[:live].present?
@replays_filter = params[:replays].present?
@entries = Teams::PublicDirectory.call(
q: @query,
sport: @sport,
live: @live_filter,
replays: @replays_filter
)
@sport_options = Sports::Catalog.as_api_list
end
def show
@team = Team.includes(:club, roster_members: []).find_by!(slug: params[:slug])
@club = @team.club
load_live_content!
end
private
def load_live_content!
@online_paths = fetch_online_paths
@live_sessions = StreamSession
.broadcasting
.publicly_listed
.where(platform: "matchlivetv")
.includes(:score_state, match: { team: :club })
.joins(:match)
.where(matches: { team_id: @team.id })
.order(Arel.sql("started_at DESC NULLS LAST"), created_at: :desc)
broadcasting_match_ids = StreamSession.broadcasting.select(:match_id)
@upcoming_matches = @team.matches
.scheduled_for_live
.where.not(id: broadcasting_match_ids)
.order(scheduled_at: :asc)
.limit(20)
@recordings = @team.recordings
.ready
.publicly_listed
.includes(stream_session: :match)
.order(recorded_at: :desc, created_at: :desc)
.limit(12)
@roster_by_category = @team.roster_public? ? @team.roster_by_category : nil
end
def fetch_online_paths
Mediamtx::Client.new.online_path_names
rescue Mediamtx::Client::Error, Errno::ECONNREFUSED, SocketError
[]
end
end
end
@@ -9,7 +9,7 @@ module Public
member = @team.roster_members.build(member_params)
attach_photo(member)
member.save!
redirect_to public_team_details_path(@team), notice: "#{member.full_name} aggiunto all'organico."
redirect_to public_team_details_path(@team), notice: t("flash.team_roster_members.added", name: member.full_name)
rescue ActiveRecord::RecordInvalid => e
redirect_to public_team_details_path(@team), alert: e.record.errors.full_messages.join(", ")
end
@@ -22,7 +22,7 @@ module Public
@member.assign_attributes(member_params)
attach_photo(@member)
@member.save!
redirect_to public_team_details_path(@team), notice: "Scheda aggiornata."
redirect_to public_team_details_path(@team), notice: t("flash.team_roster_members.updated")
rescue ActiveRecord::RecordInvalid => e
@club = @team.club
flash.now[:alert] = e.record.errors.full_messages.join(", ")
@@ -32,7 +32,7 @@ module Public
def destroy
name = @member.full_name
@member.destroy!
redirect_to public_team_details_path(@team), notice: "#{name} rimosso dall'organico."
redirect_to public_team_details_path(@team), notice: t("flash.team_roster_members.removed", name: name)
end
private
@@ -19,7 +19,8 @@ module Public
require_club_owner!(@club)
team = @club.teams.create!(team_params)
attach_branding_logo(team)
redirect_to public_club_path(@club), notice: "Squadra «#{team.name}» aggiunta."
Teams::StaffAssignment.designate_club_owner!(team: team, user: current_user)
redirect_to public_club_path(@club), notice: t("flash.teams.added", name: team.name)
rescue ActiveRecord::RecordInvalid => e
flash.now[:alert] = e.record.errors.full_messages.join(", ")
render :new, status: :unprocessable_entity
@@ -41,24 +42,33 @@ module Public
def edit
require_club_owner_for_team!(@team)
@club = @team.club
@entitlements = @team.entitlements
end
def update
require_club_owner_for_team!(@team)
@team.assign_attributes(team_params)
attach_branding_logo(@team)
attach_cover_image(@team, entitlements: @team.entitlements)
attach_team_photo(@team)
@team.save!
redirect_to public_team_details_path(@team), notice: "Squadra aggiornata."
enqueue_cover_slate_generation(@team)
redirect_to public_team_details_path(@team), notice: t("flash.teams.updated")
rescue ActiveRecord::RecordInvalid => e
@club = @team.club
@entitlements = @team.entitlements
flash.now[:alert] = e.record.errors.full_messages.join(", ")
render :edit, status: :unprocessable_entity
rescue Teams::EntitlementError, BrandingAttachments::CoverUploadError => e
@club = @team.club
@entitlements = @team.entitlements
flash.now[:alert] = e.message
render :edit, status: :unprocessable_entity
end
def invite
require_club_owner_for_team!(@team)
@entitlements = @team.entitlements
redirect_to public_team_details_path(@team, anchor: "invita-trasmissione")
end
def assign_self_staff
@@ -66,7 +76,7 @@ module Public
membership = current_user.user_teams.find_or_initialize_by(team: @team)
membership.role = "member" if membership.new_record?
Teams::StaffAssignment.call(team: @team, user: current_user, membership: membership)
redirect_to public_team_details_path(@team), notice: "Il tuo account è ora responsabile trasmissione."
redirect_to public_team_details_path(@team), notice: t("flash.teams.now_staff")
rescue Teams::StaffAssignmentError, Teams::EntitlementError => e
redirect_to public_team_details_path(@team), alert: e.message
end
@@ -75,7 +85,7 @@ module Public
require_club_owner_for_team!(@team)
membership = current_user.user_teams.find_by(team: @team)
membership&.update!(staff_kind: nil)
redirect_to public_team_details_path(@team), notice: "Ruolo staff rimosso dal tuo account."
redirect_to public_team_details_path(@team), notice: t("flash.teams.staff_role_removed")
end
def create_invitation
@@ -87,32 +97,46 @@ module Public
email = params[:email]&.downcase&.strip
Teams::StaffEmailValidator.assert_available!(team: @team, email: email, staff_kind: staff_kind)
token = TeamInvitation.generate_token
@team.team_invitations.create!(
invitation = @team.team_invitations.create!(
email: email,
token_digest: Digest::SHA256.hexdigest(token),
role: "member",
staff_kind: staff_kind,
expires_at: 7.days.from_now
)
@invite_url = join_public_invitation_url(token: token)
flash.now[:notice] = "Link invito generato (valido 7 giorni)"
render :invite
invite_url = public_invitation_url(token: token)
flash[:invite_url] = invite_url
begin
Teams::InvitationMailer.transmission_invite(
team: @team,
invitation: invitation,
invite_url: invite_url,
invited_by: current_user
).deliver_now
flash[:notice] = t("flash.teams.invite_email_sent", email: email)
rescue StandardError => e
Rails.logger.error("[invite_email] #{e.class}: #{e.message}")
flash[:alert] = t("flash.teams.invite_email_failed", email: email)
end
redirect_to public_team_details_path(@team, anchor: "invito-generato")
rescue Teams::EntitlementError, Teams::StaffAssignmentError => e
redirect_to public_team_invite_path(@team), alert: e.message
redirect_to public_team_details_path(@team, anchor: "invita-trasmissione"), alert: e.message
end
def remove_member
require_club_owner_for_team!(@team)
ut = @team.user_teams.find_by!(user_id: params[:user_id], role: "member")
ut.destroy!
redirect_to public_team_details_path(@team), notice: "Accesso revocato"
redirect_to public_team_details_path(@team), notice: t("flash.teams.access_revoked")
end
def destroy_invitation
require_club_owner_for_team!(@team)
inv = @team.team_invitations.pending.find(params[:invitation_id])
inv.destroy!
redirect_to public_team_details_path(@team), notice: "Invito annullato"
redirect_to public_team_details_path(@team), notice: t("flash.teams.invitation_canceled")
end
def youtube_connect
@@ -125,7 +149,7 @@ module Public
def youtube_disconnect
require_club_owner_for_team!(@team)
@team.club.youtube_credential&.destroy!
redirect_to public_club_path(@team.club), notice: "Canale YouTube della società scollegato"
redirect_to public_club_path(@team.club), notice: t("flash.teams.youtube_disconnected")
end
private
@@ -165,7 +189,12 @@ module Public
end
def team_params
p = params.require(:team).permit(:name, :sport, :description, :logo_url, :primary_color, :secondary_color)
p = params.require(:team).permit(:name, :sport, :sport_key, :description, :logo_url, :primary_color, :secondary_color, :slug, :roster_public)
if p[:sport].present? && p[:sport_key].blank?
p[:sport_key] = p.delete(:sport)
elsif p[:sport_key].present?
p.delete(:sport)
end
p[:primary_color] = p[:primary_color].presence
p[:secondary_color] = p[:secondary_color].presence
if p[:primary_color].present?
@@ -7,15 +7,15 @@ module Public
def require_login!
return if logged_in?
redirect_to public_login_path, alert: "Accedi per continuare"
redirect_to public_login_path, alert: t("flash.web_base.login_required")
end
def require_club_owner!(club)
redirect_to public_prezzi_path, alert: "Accesso non consentito" unless club.owned_by?(current_user)
redirect_to public_prezzi_path, alert: t("flash.web_base.access_denied") unless club.owned_by?(current_user)
end
def require_club_owner_for_team!(team)
redirect_to public_prezzi_path, alert: "Accesso non consentito" unless team.club&.owned_by?(current_user)
redirect_to public_prezzi_path, alert: t("flash.web_base.access_denied") unless team.club&.owned_by?(current_user)
end
end
end
+11
View File
@@ -0,0 +1,11 @@
# frozen_string_literal: true
module Sports
class BoardType
ALL = %w[volley basket timed racket timer generic].freeze
def self.valid?(value)
ALL.include?(value.to_s)
end
end
end
+94
View File
@@ -0,0 +1,94 @@
# frozen_string_literal: true
module Sports
class Catalog
LEGACY_SPORT_MAP = {
"volleyball" => "pallavolo"
}.freeze
class << self
def all
@all ||= load_entries
end
def keys
all.keys
end
def find(key)
normalized = normalize_key(key)
entry = all[normalized]
raise KeyError, "Sport sconosciuto: #{key}" unless entry
entry.merge(key: normalized)
end
def find_optional(key)
normalized = normalize_key(key)
entry = all[normalized]
return nil unless entry
entry.merge(key: normalized)
end
def board_for(key)
find(key)[:board]
end
def overlay_for(key)
find(key)[:overlay]
end
def allowed_overlays_for(key)
find(key)[:allowed_overlays]
end
def default_rules_for(key)
deep_stringify_keys(find(key)[:default_rules] || {})
end
def normalize_key(key)
raw = key.to_s.strip
return "pallavolo" if raw.blank?
LEGACY_SPORT_MAP.fetch(raw, raw)
end
def as_api_list
all.map do |key, entry|
{
key: key,
label: entry[:label],
board: entry[:board],
overlay: entry[:overlay],
allowed_overlays: entry[:allowed_overlays],
default_rules: entry[:default_rules] || {}
}
end
end
private
def load_entries
path = Rails.root.join("config/sports.yml")
raw = YAML.safe_load(File.read(path), permitted_classes: [], aliases: true) || {}
raw.transform_keys(&:to_s).transform_values do |entry|
entry.deep_symbolize_keys
end.freeze
end
def deep_stringify_keys(value)
case value
when Hash
value.each_with_object({}) do |(k, v), h|
h[k.to_s] = deep_stringify_keys(v)
end
when Array
value.map { |v| deep_stringify_keys(v) }
else
value
end
end
end
end
end
+11
View File
@@ -0,0 +1,11 @@
# frozen_string_literal: true
module Sports
class OverlayKind
ALL = %w[none volley basket timed racket].freeze
def self.valid?(value)
ALL.include?(value.to_s)
end
end
end
+25
View File
@@ -0,0 +1,25 @@
# frozen_string_literal: true
module Sports
class PlayerRoles
BY_BOARD = {
"volley" => %w[Schiacciatore Opposto Centrale Palleggiatore Libero Universale],
"basket" => ["Playmaker", "Guardia", "Ala", "Ala forte", "Centro"],
"racket" => %w[Giocatore],
"timed" => %w[Portiere Difensore Centrocampista Attaccante],
"generic" => %w[Giocatore],
"timer" => %w[Giocatore]
}.freeze
class << self
def for_team(team)
board = team.effective_board_type
BY_BOARD[board] || BY_BOARD["generic"]
end
def for_board(board)
BY_BOARD[board.to_s] || BY_BOARD["generic"]
end
end
end
end
+38
View File
@@ -0,0 +1,38 @@
# frozen_string_literal: true
module Sports
class RulesSchema
INTEGER_RULES = {
"volley" => %w[sets_to_win points_per_set points_deciding_set min_point_lead],
"racket" => %w[sets_to_win points_per_set points_deciding_set min_point_lead],
"basket" => %w[periods period_duration_secs overtime_duration_secs],
"timed" => %w[periods period_duration_secs overtime_duration_secs],
"timer" => [],
"generic" => []
}.freeze
def self.validate!(board_type, rules)
board = board_type.to_s
return if board == "generic" || board == "timer"
hash = rules.is_a?(Hash) ? rules : {}
INTEGER_RULES.fetch(board, []).each do |key|
val = hash[key] || hash[key.to_sym]
next if val.blank?
unless val.is_a?(Integer) || val.to_s.match?(/\A\d+\z/)
raise ArgumentError, "#{key} non valido"
end
int_val = val.to_i
raise ArgumentError, "#{key} deve essere positivo" if int_val < 1
end
end
def self.validate(board_type, rules)
validate!(board_type, rules)
true
rescue ArgumentError
false
end
end
end
+127
View File
@@ -19,4 +19,131 @@ module AdminHelper
mins = minutes % 60
hours.positive? ? "#{hours}h #{mins}m" : "#{mins} min"
end
def admin_session_watch_links(session)
links = []
if session.matchlivetv_platform?
links << { label: I18n.t("admin.common.live_page"), url: session.watch_page_url }
end
youtube = session.youtube_watch_url
links << { label: "YouTube", url: youtube } if youtube.present?
links
end
def admin_session_ingest_badge_class(node)
case node.role
when "home" then "badge--ingest-home"
when "lab" then "badge--ingest-lab"
when "cloud" then "badge--ingest-cloud"
else "badge--paused"
end
end
def admin_session_ingest_role_label(node)
I18n.t("admin.sessions.ingest.role.#{node.role}", default: node.role.to_s.humanize)
end
def admin_session_status_badge_class(status)
case status.to_s
when "live" then "badge--live"
when "connecting", "reconnecting" then "badge--connecting"
when "paused", "idle" then "badge--paused"
when "ended" then "badge--ended"
when "error" then "badge--error"
else "badge--paused"
end
end
def admin_datetime(time, with_seconds: false)
return I18n.t("admin.common.dash") if time.blank?
format = with_seconds ? "%d/%m/%Y %H:%M:%S" : "%d/%m/%Y %H:%M"
time.in_time_zone.strftime(format)
end
def admin_session_duration_label(session)
secs = session.total_duration_secs.to_i
if secs <= 0 && session.started_at
end_time = session.ended_at || (session.terminal? ? session.updated_at : Time.current)
secs = (end_time - session.started_at).to_i if end_time
end
return I18n.t("admin.common.dash") if secs <= 0
hours = secs / 3600
mins = (secs % 3600) / 60
rem = secs % 60
if hours.positive?
format("%dh %02dm %02ds", hours, mins, rem)
elsif mins.positive?
format("%dm %02ds", mins, rem)
else
"#{rem}s"
end
end
def admin_format_event_meta(metadata)
return content_tag(:span, I18n.t("admin.common.dash"), class: "muted") if metadata.blank?
pairs = metadata.to_h
return content_tag(:span, I18n.t("admin.common.dash"), class: "muted") if pairs.empty?
content_tag(:dl, class: "admin-event-meta") do
safe_join(
pairs.map do |key, value|
content_tag(:div, class: "admin-event-meta__row") do
content_tag(:dt, key.to_s) +
content_tag(:dd, admin_event_meta_value(value))
end
end
)
end
end
def admin_event_meta_value(value)
case value
when Hash, Array
content_tag(:code, value.to_json)
when TrueClass, FalseClass
value ? "true" : "false"
when NilClass
I18n.t("admin.common.dash")
else
value.to_s
end
end
def admin_regia_expires_label(iso_time)
return if iso_time.blank?
Time.zone.parse(iso_time).strftime("%d/%m/%Y %H:%M")
rescue ArgumentError, TypeError
nil
end
def announcement_status_badge(item)
case item.status
when "published" then item.severity == "critical" ? "live" : "ready"
when "archived" then "paused"
else "connecting"
end
end
def announcement_window_label(item)
start_label = item.starts_at&.in_time_zone&.strftime("%d/%m %H:%M")
end_label = item.ends_at&.in_time_zone&.strftime("%d/%m %H:%M")
if start_label && end_label
"#{start_label}#{end_label}"
elsif start_label
I18n.t("admin.announcements.window.from", time: start_label)
elsif end_label
I18n.t("admin.announcements.window.until", time: end_label)
else
I18n.t("admin.announcements.window.always")
end
end
def announcement_channels_label(item)
labels = item.selected_channels.map { |key| I18n.t("admin.announcements.channels.#{key}") }
labels.presence&.join(" · ") || I18n.t("admin.common.dash")
end
end
+49 -2
View File
@@ -9,11 +9,58 @@ module ApplicationHelper
PLAN_ICONS[plan.slug] || "fa-solid fa-circle"
end
# Data/ora nel fuso dell'app (Europe/Rome) e nella lingua del sito.
# Data/ora nel fuso dell'app (Europe/Rome) e nella lingua corrente.
def l_local(date_or_time, format: :long)
return nil if date_or_time.blank?
value = date_or_time.respond_to?(:in_time_zone) ? date_or_time.in_time_zone : date_or_time
I18n.with_locale(:it) { I18n.l(value, format: format) }
I18n.l(value, format: format)
end
def og_locale_tag
{
it: "it_IT",
en: "en_US",
fr: "fr_FR",
de: "de_DE",
es: "es_ES"
}.fetch(I18n.locale.to_sym, "it_IT")
end
def html_lang
I18n.locale.to_s
end
def confirm_dialog_i18n_json
{
eyebrow: t("common.confirm_eyebrow"),
title: t("common.confirm_title"),
deleteTitle: t("common.delete_title"),
confirmOk: t("common.confirm_ok"),
deleteOk: t("common.delete_ok"),
cancel: t("common.cancel"),
multiDeleteTitle: t("common.multi_delete_title"),
multiDeleteMessage: t("common.multi_delete_message"),
deleteYoutubeTitle: t("common.delete_youtube_title"),
deleteYoutubeMessage: t("common.delete_youtube_message"),
deleteYoutubeOk: t("common.delete_youtube_ok"),
deleteSiteTitle: t("common.delete_site_title"),
deleteSiteMessageWithYoutube: t("common.delete_site_message_with_youtube"),
deleteSiteMessageOnly: t("common.delete_site_message_only"),
deleteSiteOk: t("common.delete_site_ok"),
choiceSiteLabel: t("common.choice_site_label"),
choiceSiteHintWithYoutube: t("common.choice_site_hint_with_youtube"),
choiceSiteHintOnly: t("common.choice_site_hint_only"),
choiceYoutubeLabel: t("common.choice_youtube_label"),
choiceYoutubeHintWithSite: t("common.choice_youtube_hint_with_site"),
choiceYoutubeHintOnly: t("common.choice_youtube_hint_only"),
choiceBothLabel: t("common.choice_both_label"),
choiceBothHint: t("common.choice_both_hint")
}.to_json
end
def sport_catalog_options(selected = nil)
options = Sports::Catalog.as_api_list.map { |entry| [entry[:label], entry[:key]] }
options_for_select(options, Sports::Catalog.normalize_key(selected))
end
end
+1 -1
View File
@@ -1,6 +1,6 @@
module LegalHelper
def legal_last_updated
"3 giugno 2026"
"20 agosto 2026"
end
def cookie_policy_last_updated
+89 -12
View File
@@ -1,26 +1,59 @@
module Public
module BillingHelper
def plan_billing_action(current_slug:, target_plan:, stripe_subscription_active:, current_interval: nil, subscription: nil)
def plan_billing_action(current_slug:, target_plan:, stripe_subscription_active:, current_interval: nil, subscription: nil, club: nil)
if target_plan.slug == "free"
return { kind: :current, label: "Piano attuale" } if current_slug == "free"
return { kind: :current, label: I18n.t("billing.actions.current_plan") } if current_slug == "free"
return { kind: :none } if stripe_subscription_active
return { kind: :contact, label: "Per passare al piano Free, contatta il supporto." }
return { kind: :contact, label: I18n.t("billing.actions.contact_for_free") }
end
quote = club&.active_billing_quote
if quote
if quote.plan_slug == target_plan.slug
if current_paid_plan?(subscription, target_plan)
return {
kind: :current,
label: "#{I18n.t('billing.actions.current_plan')}#{quote.price_label}"
}
end
return { kind: :quoted, plan: target_plan, quote: quote, intervals: [quote.billing_interval] }
end
return { kind: :quoted_other, label: I18n.t("billing.bank_transfer.quoted_other") }
end
intervals = bank_transfer_intervals_for(target_plan)
unless MatchLiveTv.stripe_enabled?
return { kind: :disabled, label: "Stripe non configurato" }
if current_paid_plan?(subscription, target_plan)
return { kind: :current, label: current_plan_label(target_plan, subscription) }
end
if MatchLiveTv.bank_transfer_configured?
return { kind: :bank_only, plan: target_plan, intervals: intervals }
end
return { kind: :disabled, label: I18n.t("billing.actions.stripe_not_configured") }
end
intervals = Billing::Stripe::PriceCatalog.available_intervals(plan_slug: target_plan.slug)
return { kind: :disabled, label: "Prezzi Stripe non configurati" } if intervals.empty?
stripe_intervals = Billing::Stripe::PriceCatalog.available_intervals(plan_slug: target_plan.slug)
if stripe_intervals.empty?
if current_paid_plan?(subscription, target_plan)
return { kind: :current, label: current_plan_label(target_plan, subscription) }
end
if MatchLiveTv.bank_transfer_configured?
return { kind: :bank_only, plan: target_plan, intervals: intervals }
end
return { kind: :disabled, label: I18n.t("billing.actions.stripe_prices_not_configured") }
end
active_interval = current_interval.presence || Billing::Stripe::PriceCatalog::DEFAULT_INTERVAL
if current_slug == target_plan.slug && stripe_subscription_active && !subscription&.plan_change_pending?
other_intervals = intervals - [active_interval]
other_intervals = stripe_intervals - [active_interval]
if other_intervals.empty?
label = "Piano attuale#{Billing::Stripe::PriceCatalog.label(plan_slug: target_plan.slug, interval: active_interval)}"
label = "#{I18n.t('billing.actions.current_plan')}#{Billing::Stripe::PriceCatalog.label(plan_slug: target_plan.slug, interval: active_interval)}"
return { kind: :current, label: label }
end
@@ -34,13 +67,17 @@ module Public
}
end
if current_paid_plan?(subscription, target_plan)
return { kind: :current, label: current_plan_label(target_plan, subscription) }
end
if current_slug == "free" || !stripe_subscription_active
{ kind: :checkout_options, plan: target_plan, intervals: intervals, subscription: subscription }
{ kind: :checkout_options, plan: target_plan, intervals: stripe_intervals.presence || intervals, subscription: subscription }
else
{
kind: :change_options,
plan: target_plan,
intervals: intervals,
intervals: stripe_intervals,
current_slug: current_slug,
current_interval: active_interval,
subscription: subscription
@@ -57,6 +94,10 @@ module Public
Billing::Stripe::PlanChangeMessages.button_label(plan: plan, interval: interval, kind: btn_kind)
end
def plan_cta_name(plan)
I18n.t("pages.plans.cta_name.#{plan.slug}", default: plan.name)
end
def billing_plan_change_info_lines(subscription: nil)
Billing::Stripe::PlanChangeMessages.billing_info_lines(subscription: subscription)
end
@@ -87,10 +128,46 @@ module Public
def billing_profile_incomplete_message(club)
missing = club.billing_profile_errors
return "Completa i dati di fatturazione prima di attivare un piano premium." if missing.empty?
return I18n.t("billing.actions.profile_incomplete_default") if missing.empty?
"Mancano: #{missing.join(", ")}."
I18n.t("billing.actions.profile_missing", fields: missing.join(", "))
end
def bank_transfer_intervals_for(plan)
Billing::Stripe::PriceCatalog.catalog_intervals(plan_slug: plan.slug)
end
def bank_transfer_price_label(plan, interval, quote: nil)
if quote&.matches?(plan.slug, interval)
quote.price_label
else
Billing::Stripe::PriceCatalog.format_interval_price(
Billing::Stripe::PriceCatalog.amount_cents(plan_slug: plan.slug, interval: interval),
interval
)
end
end
def show_bank_transfer_for?(club:, plan:, interval:, quote: nil, pending_transfer: nil)
return false unless club.present? && plan.slug != "free"
return false unless MatchLiveTv.bank_transfer_configured?
return false if quote && !quote.matches?(plan.slug, interval)
return false if pending_transfer&.awaiting_payment?
return false if current_paid_plan?(club.subscription, plan)
true
end
def current_paid_plan?(subscription, target_plan)
return false unless subscription&.active? && subscription.premium?
return false if subscription.plan_change_pending?
subscription.plan.slug == target_plan.slug
end
def current_plan_label(target_plan, subscription)
interval = subscription&.billing_interval.presence || Billing::Stripe::PriceCatalog::DEFAULT_INTERVAL
"#{I18n.t('billing.actions.current_plan')}#{Billing::Stripe::PriceCatalog.label(plan_slug: target_plan.slug, interval: interval)}"
end
end
end
+48 -31
View File
@@ -1,13 +1,30 @@
module Public
module LiveHelper
def live_score_sets_label(score_state)
def live_score_sets_label(score_state, match = nil)
return nil unless score_state
"Set #{score_state.current_set} · Set vinti #{score_state.home_sets}-#{score_state.away_sets}"
board = match&.effective_board_type || score_state.effective_board_type
case board
when "basket", "timed"
live_score_period_label(match, score_state)
when "timer"
t("score.timer_label")
when "generic"
t("score.generic_label")
else
t("score.sets_label", set: score_state.current_set, home: score_state.home_sets, away: score_state.away_sets)
end
end
def live_score_period_label(match, score_state)
return t("score.fallback") unless score_state
score_state.current_period_label
end
def live_score_partials_label(score_state)
return nil unless score_state
return nil unless score_state.effective_board_type.in?(%w[volley racket])
partials = Array(score_state.set_partials)
return nil if partials.empty?
@@ -18,35 +35,41 @@ module Public
def live_scheduled_label(datetime)
return nil unless datetime
datetime.in_time_zone.strftime("%d/%m/%Y alle %H:%M")
datetime.in_time_zone.strftime(t("score.scheduled_date_format"))
end
def live_scheduled_relative(datetime)
return nil unless datetime
if datetime.to_date == Time.zone.today
"Oggi alle #{datetime.strftime('%H:%M')}"
t("score.scheduled_today", time: datetime.strftime("%H:%M"))
elsif datetime.to_date == Time.zone.tomorrow
"Domani alle #{datetime.strftime('%H:%M')}"
t("score.scheduled_tomorrow", time: datetime.strftime("%H:%M"))
else
live_scheduled_label(datetime)
end
end
def live_match_card_heading(match)
def live_match_card_heading(match, link_team: true)
team = match.team
club_name = team.club&.name.presence || "Società"
club_name = team.club&.name.presence || t("score.default_club_name")
team_slug = team.respond_to?(:slug) ? team.slug : nil
team_label = if link_team && team_slug.present?
link_to(team.name, public_team_page_path(team_slug), class: "live-card__team-link")
else
team.name
end
content_tag(:h3, class: "live-card__title") do
safe_join([
content_tag(:span, club_name, class: "live-card__club"),
content_tag(:span, "#{team.name} vs #{match.opponent_name}", class: "live-card__matchup")
content_tag(:span, safe_join([team_label, " vs ", match.opponent_name]), class: "live-card__matchup")
])
end
end
def live_match_page_heading(match)
team = match.team
club_name = team.club&.name.presence || "Società"
club_name = team.club&.name.presence || t("score.default_club_name")
content_tag(:div, class: "live-page-heading") do
safe_join([
content_tag(:p, club_name, class: "live-page-heading__club"),
@@ -56,39 +79,33 @@ module Public
end
def live_score_points_label(match, score_state)
return "" unless score_state
return t("score.fallback") unless score_state
"#{match.team.name} #{score_state.home_points} - #{score_state.away_points} #{match.opponent_name}"
end
board = match.effective_board_type
home = case board
when "basket", "timed" then score_state.basket_home_score
else score_state.home_points
end
away = case board
when "basket", "timed" then score_state.basket_away_score
else score_state.away_points
end
# Colore ospite sul tabellone: secondario società se leggibile su bianco, altrimenti blu visitatore.
def live_scorebug_away_color(team)
club = team.club
candidate = club&.effective_secondary_color.presence || "#1565c0"
return candidate unless light_hex_color?(candidate)
"#1565c0"
t("score.points_label", team: match.team.name, home: home, away: away, opponent: match.opponent_name)
end
private
def light_hex_color?(hex)
h = hex.to_s.delete_prefix("#")
return false unless h.match?(/\A\h{6}\z/i)
r = h[0..1].to_i(16)
g = h[2..3].to_i(16)
b = h[4..5].to_i(16)
(0.299 * r + 0.587 * g + 0.114 * b) > 200
end
def format_set_partial_entry(partial)
data = partial.respond_to?(:with_indifferent_access) ? partial.with_indifferent_access : partial
set_no = data[:set] || data["set"]
home = data[:home] || data["home"]
away = data[:away] || data["away"]
label = set_no.present? ? "Set #{set_no}" : "Set"
"#{label} #{home}-#{away}"
if set_no.present?
t("score.partial_entry", set: set_no, home: home, away: away)
else
t("score.partial_entry_no_number", home: home, away: away)
end
end
end
end
+114
View File
@@ -0,0 +1,114 @@
module Public
module RegiaHelper
def regia_board_partial(match)
board = match.effective_board_type
partial = "public/regia/#{board}"
lookup_context.exists?(partial, [], true) ? partial : "public/regia/volley"
end
def regia_header_subtitle(match, score)
board = match.effective_board_type
case board
when "basket", "timed"
live_score_period_label(match, score)
when "timer"
t("regia.board.stopwatch")
when "generic"
t("regia.board.generic_score")
else
t("regia.board.set_label", number: score.current_set)
end
end
def regia_js_i18n_json
{
periodUpdated: t("regia.js.period_updated"),
nowInPeriodTemplate: t("regia.js.now_in_period", period: "%{period}"),
setWonTitle: t("regia.modal.set_won_title"),
setWonBodyTemplate: t("regia.js.set_won_body", winner: "%{winner}"),
matchWonTitle: t("regia.js.match_won_title"),
matchWonBodyTemplate: t("regia.js.match_won_body", winner: "%{winner}"),
closeSetTitle: t("regia.board.close_set"),
closeSetConfirm: t("regia.js.close_set_confirm"),
scoreUpdateError: t("regia.js.score_update_error"),
genericError: t("regia.js.generic_error"),
setsLineTemplate: t("regia.js.sets_line", current: "%{current}", home: "%{home}", away: "%{away}"),
setPartialLabelTemplate: t("regia.board.set_label", number: "%{number}"),
freeScore: t("regia.board.free_score"),
stopwatch: t("regia.board.stopwatch"),
quarterLabelTemplate: t("regia.js.quarter_label", n: "%{n}"),
halfLabelTemplate: t("regia.js.half_label", n: "%{n}"),
overtimeBasketTemplate: t("regia.js.overtime_basket", n: "%{n}"),
overtimeOther: t("regia.js.overtime_other"),
unknownPeriod: t("regia.js.unknown_period"),
partialsPrefix: t("regia.board.partials_prefix"),
resumed: t("regia.js.resumed"),
pausedCover: t("regia.js.paused_cover"),
muted: t("regia.js.muted"),
unmuted: t("regia.js.unmuted"),
closed: t("regia.js.closed"),
resumeError: t("regia.js.resume_error"),
pauseError: t("regia.js.pause_error"),
muteError: t("regia.js.mute_error"),
closeError: t("regia.js.close_error"),
closeConfirm: t("regia.js.close_confirm"),
pauseConfirmTitle: t("regia.js.pause_confirm_title"),
pauseConfirmBody: t("regia.js.pause_confirm_body"),
resumeConfirmTitle: t("regia.js.resume_confirm_title"),
resumeConfirmBody: t("regia.js.resume_confirm_body"),
muteConfirmTitle: t("regia.js.mute_confirm_title"),
muteConfirmBody: t("regia.js.mute_confirm_body"),
unmuteConfirmTitle: t("regia.js.unmute_confirm_title"),
unmuteConfirmBody: t("regia.js.unmute_confirm_body"),
advancePeriodTitle: t("regia.js.advance_period_title"),
advancePeriodBody: t("regia.js.advance_period_body"),
confirmAction: t("regia.modal.confirm_action"),
stopConfirmTitle: t("regia.js.stop_confirm_title"),
linkUnavailable: t("regia.js.link_unavailable"),
linkCopied: t("regia.js.link_copied"),
copyPrompt: t("regia.js.copy_prompt"),
shareRegiaText: t("regia.js.share_regia_text"),
shareLiveText: t("regia.js.share_live_text"),
minQualityUpdated: t("regia.js.min_quality_updated"),
minQualityError: t("regia.js.min_quality_error"),
minQualityAuto: t("regia.min_quality.auto"),
previewWaiting: t("regia.preview_waiting"),
streamEnded: t("regia.preview_ended"),
previewHide: t("regia.preview_hide"),
previewShow: t("regia.preview_show"),
previewOffHint: t("regia.preview_off_hint"),
resumeLabel: t("regia.resume"),
pauseLabel: t("regia.pause"),
muteLabel: t("regia.mute"),
unmuteLabel: t("regia.unmute"),
endedBadge: t("regia.status.ended"),
pausedBadge: t("regia.status.paused"),
liveBadge: t("regia.status.live"),
waitingBadge: t("regia.status.waiting"),
subtitlePrefix: t("regia.subtitle_prefix"),
telemetryWaiting: t("regia.telemetry.waiting"),
telemetryStale: t("regia.telemetry.stale"),
telemetryJustNow: t("regia.telemetry.just_now"),
telemetrySecondsAgo: t("regia.telemetry.seconds_ago"),
telemetryPublisherOn: t("regia.telemetry.publisher_on"),
telemetryPublisherOff: t("regia.telemetry.publisher_off"),
telemetryThermalNominal: t("regia.telemetry.thermal_nominal"),
telemetryThermalFair: t("regia.telemetry.thermal_fair"),
telemetryThermalSerious: t("regia.telemetry.thermal_serious"),
telemetryThermalCritical: t("regia.telemetry.thermal_critical"),
telemetryBitrateMax: t("regia.telemetry.bitrate_max"),
telemetryBitrateTitle: t("regia.telemetry.bitrate_title"),
telemetryBitrateTap: t("regia.telemetry.bitrate_tap")
}.to_json
end
def format_clock_secs(secs, count_up: false)
total = secs.to_i
return count_up ? "0:00" : "" if total <= 0 && !count_up
m = total / 60
s = total % 60
format("%d:%02d", m, s)
end
end
end
@@ -0,0 +1,25 @@
module Public
module TeamPagesHelper
def team_page_meta_description(team, club)
t("team_pages.show.meta_description", team: team.name, club: club.name)
end
def filter_params_for_canonical
params.permit(:q, :sport, :live, :replays).to_h.compact_blank
end
def team_page_structured_data(team, club)
{
"@context" => "https://schema.org",
"@type" => "SportsTeam",
"name" => team.name,
"sport" => team.sport_label,
"url" => seo_absolute_url(public_team_page_path(team.slug)),
"memberOf" => {
"@type" => "SportsOrganization",
"name" => club.name
}
}
end
end
end
@@ -0,0 +1,24 @@
module RecordingsArchiveHelper
ArchivePaths = Struct.new(:index, :update, :destroy, :publish_youtube, :back, keyword_init: true)
def recordings_archive_paths(club, namespace:)
case namespace
when :admin
ArchivePaths.new(
index: admin_club_recordings_path(club),
update: ->(rec) { admin_club_recording_path(club, rec) },
destroy: ->(rec) { admin_club_recording_path(club, rec) },
publish_youtube: ->(rec) { publish_youtube_admin_club_recording_path(club, rec) },
back: admin_club_path(club)
)
else
ArchivePaths.new(
index: public_club_recordings_path(club),
update: ->(rec) { public_club_recording_path(club, rec) },
destroy: ->(rec) { public_club_recording_path(club, rec) },
publish_youtube: ->(rec) { public_club_recording_publish_youtube_path(club, rec) },
back: public_club_path(club)
)
end
end
end
+4 -3
View File
@@ -19,13 +19,14 @@ module RosterHelper
def roster_category_options
TeamRosterMember::DISPLAY_ORDER.map do |cat|
[TeamRosterMember::CATEGORY_LABELS[cat], cat]
[TeamRosterMember.category_label(cat), cat]
end
end
def roster_player_role_options(selected = nil)
def roster_player_role_options(team, selected = nil)
roles = Sports::PlayerRoles.for_team(team)
options_for_select(
[["— Seleziona ruolo —", ""]] + TeamRosterMember::VOLLEYBALL_PLAYER_ROLES.map { |r| [r, r] },
[[t("roster.role_select_blank"), ""]] + roles.map { |r| [r, r] },
selected
)
end
@@ -0,0 +1,25 @@
# frozen_string_literal: true
module Analytics
class AggregateJob < ApplicationJob
queue_as :default
def perform(purge: false)
redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
lock_key = "analytics:aggregate:lock"
acquired = redis.set(lock_key, "1", nx: true, ex: 120)
return unless acquired
begin
service = Analytics::Aggregate.new
service.call
service.purge_old! if purge
ensure
redis.del(lock_key)
end
rescue StandardError => e
Rails.logger.error("[analytics] aggregate failed: #{e.class} #{e.message}")
raise
end
end
end
@@ -6,7 +6,7 @@ class CleanupExpiredSessionsJob
.where("updated_at < ?", 6.hours.ago)
.find_each do |session|
session.fail! if session.may_fail?
Mediamtx::Client.new.delete_path(session)
Mediamtx::Client.for_session(session).delete_path(session)
end
end
end
@@ -6,12 +6,37 @@ class ExpireEndedSubscriptionsJob
free_plan = Plan["free"]
Subscription.where(cancel_at_period_end: true)
.where("current_period_end <= ?", Time.current)
.where.not(plan_id: free_plan.id)
.find_each do |sub|
Billing::Stripe::FinalizeSubscription.call(club: sub.club)
.where("current_period_end <= ?", Time.current)
.where.not(plan_id: free_plan.id)
.where(admin_comped: false)
.find_each do |sub|
expire!(sub)
rescue StandardError => e
Rails.logger.warn("[ExpireEndedSubscriptions] club=#{sub.club_id} #{e.message}")
end
end
private
def expire!(sub)
if sub.stripe_subscription_id.present?
Billing::Stripe::FinalizeSubscription.call(club: sub.club)
else
Billing::AssignPlan.call(
club: sub.club,
plan_slug: "free",
status: "active",
stripe_attrs: {
stripe_subscription_id: nil,
stripe_schedule_id: nil,
current_period_start: nil,
current_period_end: nil,
cancel_at_period_end: false,
billing_interval: nil,
pending_plan_id: nil,
pending_billing_interval: nil
}
)
end
end
end
@@ -0,0 +1,19 @@
class GenerateCoverSlateJob < ApplicationJob
queue_as :default
def self.enqueue_for(record)
return unless record&.cover_image&.attached?
queue = record.is_a?(Match) ? :critical : :default
set(queue: queue).perform_later(record.class.name, record.id)
end
def perform(class_name, record_id)
record = class_name.constantize.find_by(id: record_id)
return unless record&.cover_image&.attached?
Streams::GenerateCoverSlate.call(record)
rescue Streams::GenerateCoverSlate::Error => e
Rails.logger.warn("[GenerateCoverSlateJob] #{class_name}##{record_id} #{e.message}")
end
end
@@ -0,0 +1,33 @@
module Ops
class HealthMonitorJob
include Sidekiq::Job
sidekiq_options retry: 1, queue: "default"
INTERVAL_SECS = ENV.fetch("OPS_HEALTH_INTERVAL_SECS", "180").to_i
REDIS_CHAIN_KEY = "ops:health_monitor:chain"
HEARTBEAT_KEY = "ops:health_monitor:last_run"
def self.ensure_chain
return unless redis
return if redis.get(REDIS_CHAIN_KEY)
redis.set(REDIS_CHAIN_KEY, "1", ex: INTERVAL_SECS * 2)
perform_in(INTERVAL_SECS)
end
def self.redis
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
rescue Redis::CannotConnectError
nil
end
def perform
self.class.redis&.set(HEARTBEAT_KEY, Time.current.to_i, ex: INTERVAL_SECS * 3)
Ops::HealthChecks.new.call
ensure
self.class.perform_in(INTERVAL_SECS)
end
end
end
@@ -0,0 +1,16 @@
module Recordings
class CleanupLocalJob
include Sidekiq::Job
sidekiq_options retry: 1, queue: "default"
def perform
result = Recordings::CleanupLocal.new.call
orphan = Mediamtx::CleanupOrphanPaths.new.call
Rails.logger.info(
"[Recordings::CleanupLocalJob] removed=#{result.removed} " \
"freed=#{result.freed_bytes} skipped=#{result.skipped} " \
"orphan_paths=#{orphan.removed}"
)
end
end
end
@@ -0,0 +1,17 @@
module Recordings
class SyncYoutubePrivacyJob
include Sidekiq::Job
sidekiq_options retry: 2, queue: "default"
def perform(recording_id)
recording = Recording.find_by(id: recording_id)
return unless recording&.youtube_video_id.present?
Youtube::VideoLifecycleService.new(recording).update_privacy!(
privacy_status: recording.privacy_status
)
rescue Youtube::VideoLifecycleService::Error => e
Rails.logger.warn("[SyncYoutubePrivacyJob] #{recording_id}: #{e.message}")
end
end
end
@@ -0,0 +1,33 @@
# frozen_string_literal: true
module Streams
class AutoscalerJob
include Sidekiq::Job
sidekiq_options retry: 1, queue: "default"
INTERVAL_SECS = ENV.fetch("STREAM_AUTOSCALE_INTERVAL_SECS", "60").to_i
REDIS_CHAIN_KEY = "streams:autoscaler:chain"
def self.ensure_chain
return unless redis
return if redis.get(REDIS_CHAIN_KEY)
redis.set(REDIS_CHAIN_KEY, "1", ex: INTERVAL_SECS * 2)
perform_in(INTERVAL_SECS)
end
def self.redis
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
rescue Redis::CannotConnectError
nil
end
def perform
Streams::Autoscaler.reconcile!
ensure
self.class.redis&.set(REDIS_CHAIN_KEY, "1", ex: INTERVAL_SECS * 2)
self.class.perform_in(INTERVAL_SECS)
end
end
end
+2 -2
View File
@@ -1,6 +1,6 @@
# Avvia/riavvia il relay YouTube solo nel container sidekiq (YOUTUBE_RELAY_WORKER=1).
# Avvia/riavvia il relay YouTube sul worker del nodo (coda youtube_relay_<slug>).
class YoutubeRelayEnsureJob < ApplicationJob
queue_as :default
queue_as Streams::YoutubeRelay::QUEUE
def perform(session_id)
session = StreamSession.find_by(id: session_id)
+16 -3
View File
@@ -1,10 +1,23 @@
# Ferma il relay solo sull'owner. Se il job gira su un altro host, requeue breve.
class YoutubeRelayStopJob < ApplicationJob
queue_as :default
queue_as Streams::YoutubeRelay::QUEUE
def perform(session_id)
discard_on ActiveJob::DeserializationError
def perform(session_id, attempts = 0)
session = StreamSession.find_by(id: session_id)
return unless session
Streams::YoutubeRelay.stop_on_worker!(session) if Streams::YoutubeRelay.worker?
unless Streams::YoutubeRelay.worker?
# Solo i worker relay processano questa coda in modo utile.
return
end
result = Streams::YoutubeRelay.stop_on_worker!(session)
return unless result == :wrong_host
return if attempts >= 30
self.class.set(wait: 2.seconds, queue: Streams::YoutubeRelay.queue_for(session))
.perform_later(session_id, attempts + 1)
end
end
@@ -0,0 +1,35 @@
module Billing
class BankTransferMailer < ApplicationMailer
def instructions
@order = params[:order]
@club = @order.club
@iban = MatchLiveTv.bank_transfer_iban
@holder = MatchLiveTv.bank_transfer_account_holder
@bank_name = MatchLiveTv.bank_transfer_bank_name
@bic = MatchLiveTv.bank_transfer_bic
@proof_email = MatchLiveTv.bank_transfer_proof_email
mail(
to: recipient_email,
subject: t("mailers.bank_transfer.instructions.subject", plan: @order.plan.name)
)
end
def plan_activated
@order = params[:order]
@club = @order.club
@subscription = @club.subscription
mail(
to: recipient_email,
subject: t("mailers.bank_transfer.plan_activated.subject", plan: @order.plan.name)
)
end
private
def recipient_email
@club.billing_email.presence || @club.owner&.email
end
end
end
+29 -6
View File
@@ -4,15 +4,38 @@ module Billing
@invoice = params[:invoice]
@club = @invoice.club
attachments["fattura-#{@invoice.number.parameterize}.pdf"] = {
I18n.with_locale(I18n.locale) do
attach_invoice_pdf!
mail(
to: @club.billing_email,
subject: t("mailers.invoice.subject", number: @invoice.display_number)
)
end
end
def plan_activated_with_invoice
@invoice = params[:invoice]
@club = @invoice.club
I18n.with_locale(I18n.locale) do
attach_invoice_pdf!
mail(
to: @club.billing_email,
subject: t("mailers.invoice_activated.subject",
plan: @club.subscription&.plan&.name || @invoice.display_number,
number: @invoice.display_number)
)
end
end
private
def attach_invoice_pdf!
prefix = t("mailers.invoice.attachment_prefix")
attachments["#{prefix}-#{@invoice.number.parameterize}.pdf"] = {
mime_type: "application/pdf",
content: @invoice.pdf.download
}
mail(
to: @club.billing_email,
subject: "Fattura #{@invoice.display_number} — Match Live TV"
)
end
end
end
+25
View File
@@ -0,0 +1,25 @@
class ContactMailer < ApplicationMailer
def inquiry(name:, email:, club_name:, topic:, message:)
@name = name
@email = email
@club_name = club_name.presence
@topic = topic
@message = message
mail(
to: recipient_for(topic),
reply_to: email,
subject: t("mailers.contact.subject.#{topic}", name: name)
)
end
private
def recipient_for(topic)
if topic == "privacy"
MatchLiveTv.privacy_controller_email
else
MatchLiveTv.support_email
end
end
end
+11
View File
@@ -0,0 +1,11 @@
module Ops
class AlertMailer < ApplicationMailer
def incident(incident)
@incident = incident
mail(
to: ENV.fetch("OPS_ALERT_EMAIL"),
subject: "[Match Live TV Ops] #{incident.severity.upcase}: #{incident.title}"
)
end
end
end
@@ -9,11 +9,14 @@ module Recordings
@recipient = recipient
@replay_url = recording.replay_url
@expires_at = recording.expires_at
@archive_url = "#{MatchLiveTv.app_public_url.chomp('/')}/clubs/#{@club.id}/replays"
mail(
to: recipient.email,
subject: "Replay pronto — #{recording.title_or_default}"
)
I18n.with_locale(I18n.locale) do
mail(
to: recipient.email,
subject: t("mailers.replay_ready.subject", title: recording.title_or_default)
)
end
end
def replay_expiring_soon(recording:, recipient:)
@@ -24,11 +27,14 @@ module Recordings
@replay_url = recording.replay_url
@expires_at = recording.expires_at
@days_left = ((recording.expires_at - Time.current) / 1.day).ceil
@archive_url = "#{MatchLiveTv.app_public_url.chomp('/')}/clubs/#{@club.id}/replays"
mail(
to: recipient.email,
subject: "Replay in scadenza tra #{@days_left} giorni — #{recording.title_or_default}"
)
I18n.with_locale(I18n.locale) do
mail(
to: recipient.email,
subject: t("mailers.replay_expiring.subject", days: @days_left, title: recording.title_or_default)
)
end
end
end
end
@@ -0,0 +1,25 @@
module Teams
class InvitationMailer < ApplicationMailer
default from: -> { MatchLiveTv.mail_from }
def transmission_invite(team:, invitation:, invite_url:, invited_by:)
@team = team
@club = team.club
@invitation = invitation
@invite_url = invite_url
@invited_by = invited_by
@expires_on = I18n.l(invitation.expires_at.to_date, format: :long)
I18n.with_locale(I18n.locale) do
mail(
to: invitation.email,
subject: t(
"mailers.transmission_invite.subject",
team: @team.name,
club: @club.name
)
)
end
end
end
end
+3 -1
View File
@@ -6,6 +6,8 @@ class UserMailer < ApplicationMailer
@reset_url = public_password_reset_url(token: token)
@expiry_hours = MatchLiveTv.password_reset_expiry_hours
mail to: user.email, subject: "Reimposta la password — Match Live TV"
I18n.with_locale(I18n.locale) do
mail to: user.email, subject: t("mailers.password_reset.subject")
end
end
end
+3
View File
@@ -1,5 +1,8 @@
class AdminAccount < ApplicationRecord
include PasswordComplexity
has_secure_password
has_many :app_announcements, foreign_key: :created_by_admin_id, dependent: :nullify, inverse_of: :created_by_admin
validates :username, presence: true, uniqueness: true
end
+14
View File
@@ -0,0 +1,14 @@
# frozen_string_literal: true
class AnalyticsEvent < ApplicationRecord
self.table_name = "analytics_events"
EVENT_TYPES = %w[click scroll pageview move].freeze
DEVICES = %w[mobile tablet desktop].freeze
validates :page_path, presence: true
validates :device, inclusion: { in: DEVICES }
validates :event_type, inclusion: { in: EVENT_TYPES }
validates :occurred_at, presence: true
validates :weight, numericality: { greater_than: 0, less_than_or_equal_to: 500 }
end
+13
View File
@@ -0,0 +1,13 @@
# frozen_string_literal: true
class AnalyticsPageCell < ApplicationRecord
self.table_name = "analytics_page_cells"
GRID_SIZE = 40
validates :day, :page_path, :device, :cell_x, :cell_y, presence: true
validates :device, inclusion: { in: AnalyticsEvent::DEVICES }
validates :cell_x, :cell_y, inclusion: { in: 0...(GRID_SIZE) }
validates :click_count, numericality: { greater_than_or_equal_to: 0 }
validates :move_count, numericality: { greater_than_or_equal_to: 0 }
end
@@ -0,0 +1,12 @@
# frozen_string_literal: true
class AnalyticsPageSnapshot < ApplicationRecord
self.table_name = "analytics_page_snapshots"
has_one_attached :image
validates :page_path, presence: true
validates :device, inclusion: { in: AnalyticsEvent::DEVICES }
validates :width, :height, numericality: { greater_than: 0 }
validates :captured_at, presence: true
end
+14
View File
@@ -0,0 +1,14 @@
# frozen_string_literal: true
class AnalyticsPageStat < ApplicationRecord
self.table_name = "analytics_page_stats"
validates :day, :page_path, :device, presence: true
validates :device, inclusion: { in: AnalyticsEvent::DEVICES }
def avg_scroll_pct
return 0 if scroll_samples.to_i <= 0
(scroll_sum_pct.to_f / scroll_samples).round
end
end
+240
View File
@@ -0,0 +1,240 @@
class AppAnnouncement < ApplicationRecord
KINDS = %w[info maintenance update].freeze
SEVERITIES = %w[info warning critical].freeze
STATUSES = %w[draft published archived].freeze
CHANNELS = {
android: :show_on_android,
ios: :show_on_ios,
web_public: :show_on_web_public,
web_private: :show_on_web_private
}.freeze
COPY_KEYS = %w[title body action_url action_label].freeze
URL_KEYS = %w[action_url].freeze
TITLE_KEYS = %w[title].freeze
BODY_KEYS = %w[body].freeze
LABEL_KEYS = %w[action_label].freeze
LOCALE_FALLBACKS = {
"it" => %w[it],
"en" => %w[en it],
"fr" => %w[fr en it],
"de" => %w[de en it],
"es" => %w[es en it]
}.freeze
belongs_to :created_by_admin, class_name: "AdminAccount", optional: true
validates :kind, :severity, :status, :title, :body, presence: true
validates :kind, inclusion: { in: KINDS }
validates :severity, inclusion: { in: SEVERITIES }
validates :status, inclusion: { in: STATUSES }
validates :title, length: { maximum: 120 }, allow_blank: true
validates :body, length: { maximum: 2000 }, allow_blank: true
validates :action_label, length: { maximum: 40 }, allow_blank: true
URL_KEYS.each do |field|
validates field, format: { with: /\Ahttps?:\/\/.+/i, allow_blank: true }
end
validate :ends_at_after_starts_at
validate :at_least_one_channel
validate :translations_are_valid
before_validation :normalize_translations
scope :newest, -> { order(created_at: :desc) }
scope :published, -> { where(status: "published") }
scope :active_now, lambda {
now = Time.current
published
.where("starts_at IS NULL OR starts_at <= ?", now)
.where("ends_at IS NULL OR ends_at > ?", now)
}
def self.for_channel(channel)
column = CHANNELS[channel.to_sym]
raise ArgumentError, "unknown announcement channel: #{channel}" if column.blank?
active_now.where(column => true)
end
def self.for_app
active_now.where("show_on_android = TRUE OR show_on_ios = TRUE")
end
def self.for_platform(platform)
case platform.to_s
when "android" then for_channel(:android)
when "ios" then for_channel(:ios)
else for_app
end
end
def published?
status == "published"
end
def selected_channels
CHANNELS.each_with_object([]) do |(key, column), keys|
keys << key if public_send(column)
end
end
def copy_values_for(locale)
loc = locale.to_s
if default_locale?(loc)
COPY_KEYS.index_with { |key| public_send(key).to_s.presence }
else
translation_hash(loc)
end
end
def filled_locale_codes
LocaleResolver.available.select { |loc| locale_has_copy?(loc) }.map(&:to_s)
end
def as_api_json(platform: nil, locale: I18n.locale)
{
id: id,
kind: kind,
severity: severity,
title: copy_for("title", locale: locale),
body: copy_for("body", locale: locale),
dismissible: dismissible,
action_url: resolved_action_url(platform, locale: locale),
action_label: copy_for("action_label", locale: locale).presence,
starts_at: starts_at&.iso8601,
ends_at: ends_at&.iso8601
}
end
def localized_title(locale: I18n.locale)
copy_for("title", locale: locale)
end
def localized_body(locale: I18n.locale)
copy_for("body", locale: locale)
end
def localized_action_label(locale: I18n.locale)
copy_for("action_label", locale: locale)
end
def resolved_action_url(platform, locale: I18n.locale)
url = copy_for("action_url", locale: locale)
return url if url.present?
return nil unless kind == "update"
case platform.to_s
when "android" then MatchLiveTv.play_store_url
when "ios" then MatchLiveTv.app_store_url
end
end
private
def copy_for(field, locale: I18n.locale)
field = field.to_s
locale_chain(locale).each do |loc|
value = value_for(loc, field)
return value if value.present?
end
nil
end
def value_for(locale, key)
copy_values_for(locale)[key].presence
end
def translation_hash(locale)
raw = translations.is_a?(Hash) ? translations : {}
values = raw.stringify_keys[locale.to_s]
return {} unless values.is_a?(Hash)
values.stringify_keys
end
def locale_chain(locale)
loc = (LocaleResolver.normalize(locale) || I18n.default_locale).to_s
LOCALE_FALLBACKS[loc] || [loc, I18n.default_locale.to_s].uniq
end
def locale_has_copy?(locale)
values = copy_values_for(locale)
values["title"].present? || values["body"].present?
end
def default_locale?(locale)
locale.to_s == I18n.default_locale.to_s
end
def normalize_translations
self.translations = self.class.sanitize_translations(translations)
end
def self.sanitize_translations(value)
raw = coerce_translation_hash(value)
LocaleResolver.available.each_with_object({}) do |loc, acc|
next if loc.to_s == I18n.default_locale.to_s
payload = raw[loc.to_s]
next unless payload.is_a?(Hash)
cleaned = COPY_KEYS.each_with_object({}) do |key, fields|
text = payload[key].to_s.strip
fields[key] = text if text.present?
end
acc[loc.to_s] = cleaned if cleaned.any?
end
end
def self.coerce_translation_hash(value)
hash = if value.is_a?(ActionController::Parameters)
value.to_unsafe_h
elsif value.is_a?(Hash)
value
else
{}
end
hash.deep_stringify_keys
end
private_class_method :coerce_translation_hash
def at_least_one_channel
return if show_on_android? || show_on_ios? || show_on_web_public? || show_on_web_private?
errors.add(:base, :no_channel)
end
def ends_at_after_starts_at
return if starts_at.blank? || ends_at.blank?
return if ends_at > starts_at
errors.add(:ends_at, :invalid)
end
def translations_are_valid
translation_entries.each do |locale, payload|
TITLE_KEYS.each do |key|
errors.add(:base, :translation_too_long, locale: locale, field: key) if payload[key].to_s.length > 120
end
BODY_KEYS.each do |key|
errors.add(:base, :translation_too_long, locale: locale, field: key) if payload[key].to_s.length > 2000
end
LABEL_KEYS.each do |key|
errors.add(:base, :translation_too_long, locale: locale, field: key) if payload[key].to_s.length > 40
end
URL_KEYS.each do |key|
url = payload[key].to_s
next if url.blank? || url.match?(/\Ahttps?:\/\/.+/i)
errors.add(:base, :translation_invalid_url, locale: locale)
end
end
end
def translation_entries
raw = translations.is_a?(Hash) ? translations : {}
raw.stringify_keys.filter_map do |locale, payload|
next unless payload.is_a?(Hash)
[locale, payload.stringify_keys]
end
end
end
+34
View File
@@ -0,0 +1,34 @@
module Billing
class ClubQuote < ApplicationRecord
self.table_name = "billing_club_quotes"
PLAN_SLUGS = %w[premium_light premium_full].freeze
INTERVALS = Billing::Stripe::PriceCatalog::INTERVALS
belongs_to :club
belongs_to :created_by_admin, class_name: "AdminAccount", optional: true
validates :plan_slug, inclusion: { in: PLAN_SLUGS }
validates :billing_interval, inclusion: { in: INTERVALS }
validates :amount_cents, numericality: { greater_than: 0 }
validates :currency, presence: true
scope :active, -> { where(active: true) }
def matches?(plan_slug, interval)
self.plan_slug == plan_slug.to_s && billing_interval == interval.to_s
end
def plan
Plan[plan_slug]
end
def formatted_amount
Billing::Stripe::PriceCatalog.format_eur(amount_cents)
end
def price_label
Billing::Stripe::PriceCatalog.format_interval_price(amount_cents, billing_interval)
end
end
end
+10 -2
View File
@@ -2,14 +2,17 @@ module Billing
class Payment < ApplicationRecord
self.table_name = "billing_payments"
STATUSES = %w[paid failed refunded].freeze
STATUSES = %w[pending paid failed refunded].freeze
PROVIDERS = %w[stripe bank_transfer].freeze
belongs_to :club
has_one :invoice, class_name: "Billing::Invoice", foreign_key: :billing_payment_id, dependent: :nullify
has_one :transfer_order, class_name: "Billing::TransferOrder", foreign_key: :billing_payment_id, dependent: :nullify
validates :amount_cents, numericality: { greater_than: 0 }
validates :currency, presence: true
validates :status, inclusion: { in: STATUSES }
validates :provider, inclusion: { in: PROVIDERS }
validates :stripe_invoice_id, uniqueness: true, allow_nil: true
scope :recent, -> { order(paid_at: :desc, created_at: :desc) }
@@ -62,7 +65,12 @@ module Billing
end
def display_status
{ "paid" => "Pagato", "failed" => "Non riuscito", "refunded" => "Rimborsato" }[status] || status
{
"pending" => "In attesa di bonifico",
"paid" => "Pagato",
"failed" => "Non riuscito",
"refunded" => "Rimborsato"
}[status] || status
end
def invoice_for_display

Some files were not shown because too many files have changed in this diff Show More