DD-018: collegamento automatico giocatore-account per email

Al primo accesso l'app collega da sola l'account Google al giocatore
la cui email registrata coincide (case-insensitive), invece di far
scegliere il nome da un elenco. Senza corrispondenza compare solo un
messaggio d'errore con un pulsante per uscire e riprovare con un altro
account: nessuna scelta manuale di ripiego.

- Migration m5: colonna `email` su giocatori_squadra, seed per Ivan
  Cacciari e Davide Grilli, trigger esteso per richiedere anche il
  match email oltre allo slot libero.
- slotPerEmail() sostituisce slotLiberi() (rimossa, senza più
  chiamanti in produzione).

Co-Authored-By: Claude Mythos  <noreply@anthropic.com>
This commit is contained in:
2026-09-01 10:46:54 +02:00
co-authored by Claude Mythos
parent 127e7c76e9
commit 215af4bbc4
11 changed files with 190 additions and 90 deletions
+9 -3
View File
@@ -36,7 +36,9 @@ admin) implementati su `develop`, da attivare in produzione seguendo i passaggi
## Database
- Schema v1.0 + M1 applicati al nuovo Supabase
- `public.giocatori_squadra`: 17 giocatori iniziali presenti
- `public.giocatori_squadra`: 17 giocatori iniziali presenti; solo 2 hanno l'email
registrata (`email`, migration `m5_email_giocatori_squadra`, DD-018) — le altre 15
arriveranno con una migration futura
---
@@ -86,8 +88,12 @@ Passaggi in ordine, nessuno dei quali è reversibile a metà:
produzione senza toccare il comportamento attuale.
3. **Primo admin**, dopo il primo login (l'ID esiste solo da quel momento):
`INSERT INTO public.user_roles (user_id, role) SELECT id, 'admin' FROM auth.users WHERE email = '<mail>';`
4. **Collegamento dei 17 account**: ciascuno accede con Google e sceglie il proprio nome una
volta sola. Uno slot già collegato può essere liberato solo da un admin.
4. **Collegamento dei 17 account**: ciascuno accede con Google e viene collegato in
automatico al proprio giocatore per email (DD-018, migration
`m5_email_giocatori_squadra`) — nessuna scelta manuale. Finché l'email di un giocatore
non è impostata (oggi solo 2 dei 17 la hanno), il suo accesso mostra un errore e va
sbloccato aggiungendo l'email con una nuova migration. Uno slot già collegato può essere
liberato solo da un admin.
5. **Solo a squadra collegata**: migration `m4_solo_autenticati`, che toglie al ruolo `anon`
l'accesso alle tabelle v1.0. Da lì in poi i dati sono raggiungibili solo con una sessione;
le route in `src/routes/api/public/` usano la service role e continuano a funzionare.
+5
View File
@@ -18,6 +18,11 @@ qui: sta in [ROADMAP.md](ROADMAP.md).
di nomi in `crapp-data.ts` è stata eliminata, altrimenti bastava scegliere il nome giusto
per amministrare.
- Migration `m4_solo_autenticati`: toglie al ruolo `anon` l'accesso alle tabelle v1.0.
- Collegamento automatico al proprio giocatore per email (DD-018, migration
`m5_email_giocatori_squadra`): niente più scelta manuale da un elenco, `/benvenuto`
confronta l'email dell'account Google con `giocatori_squadra.email` e collega da solo.
Senza corrispondenza compare solo un messaggio d'errore, con un pulsante per uscire e
riprovare con un altro account.
**Da applicare solo a squadra collegata**, altrimenti chi non ha ancora fatto login vede
l'app vuota.
- Profilo giocatore: da `/profilo` ognuno compila i propri dati anagrafici e carica
+1 -1
View File
@@ -9,7 +9,7 @@ non in questo file.
| Tabella | Scopo | Note |
|---|---|---|
| `giocatori_squadra` | Anagrafica operativa della squadra, con ID testuali (`g1``gN`), dati gestiti dagli admin (nome, cognome, numero, ruolo) e collegamento all'account (`auth_user_id`). | Introdotta dalla migration `m1_giocatori_squadra`, già popolata (17 giocatori) ma **non ancora letta dal codice**: la rosa arriva tuttora da `src/lib/crapp-data.ts`, che resta il fallback anche dopo il passaggio. Destinata a diventare la source of truth. Vedi DD-015 e DD-016. |
| `giocatori_squadra` | Anagrafica operativa della squadra, con ID testuali (`g1``gN`), dati gestiti dagli admin (nome, cognome, numero, ruolo), collegamento all'account (`auth_user_id`) ed email registrata (`email`). | Introdotta dalla migration `m1_giocatori_squadra`, già popolata (17 giocatori) ma **non ancora letta dal codice**: la rosa arriva tuttora da `src/lib/crapp-data.ts`, che resta il fallback anche dopo il passaggio. Destinata a diventare la source of truth. Vedi DD-015 e DD-016. La colonna `email` (migration `m5_email_giocatori_squadra`) è la chiave del collegamento automatico account↔giocatore al primo accesso (DD-018): NULL finché non nota, oggi impostata solo per 2 dei 17 giocatori. |
| `giocatori` | Anagrafica giocatori con UUID. | Presente ma **non usata** dal codice attuale: la convergenza è rinviata (DD-012, DD-014). |
| `profili_giocatore` | Dati personali, metadati del documento d'identità, certificato medico e path dei file, in relazione 1:1 con `giocatori_squadra`. | Creata dalla migration `m2_profili_giocatore` (DD-016). Letta da `src/lib/profili.ts`; le policy mostrano al giocatore solo il proprio profilo e all'admin tutti. I file non stanno qui: la tabella conserva i path nel bucket. |
| `user_roles` | Ruoli applicativi (es. amministratore, giocatore). | Fonte dei permessi di amministrazione, letta da `src/lib/ruoli.ts` (DD-011). Il primo admin va inserito a mano; vedi [PROJECT_STATE.md](../PROJECT_STATE.md). |
+27
View File
@@ -33,6 +33,7 @@ Serve a rispondere a domande del tipo:
| [DD-013](#dd-013--portabilità-lapp-non-deve-dipendere-da-servizi-esclusivi) | Portabilità dello stack |
| [DD-016](#dd-016--schema-dati-profilo-giocatore-v11-f0) | Schema dati Profilo Giocatore v1.1 |
| [DD-017](#dd-017--lamministratore-può-compilare-i-dati-al-posto-del-giocatore) | L'admin scrive al posto del giocatore |
| [DD-018](#dd-018--collegamento-automatico-giocatoreaccount-per-email) | Collegamento automatico per email |
**In valutazione**
@@ -474,6 +475,32 @@ Restano fuori, e non cambiano:
---
### DD-018 — Collegamento automatico giocatore↔account per email
**Data:** settembre 2026
**Stato:** Accettata
**Contesto**
DD-016 regola 2 prevedeva che, al primo accesso, il giocatore scegliesse manualmente il proprio slot libero da un elenco (`/benvenuto`). In pratica ogni giocatore ha un'email nota (o presto nota), quindi far scegliere un nome da una lista è un passaggio superfluo e un rischio: un giocatore può selezionare per errore lo slot di un compagno, e nulla nel flusso lo impedisce a livello di prodotto.
**Decisione**
Al primo accesso, `giocatori_squadra` viene interrogata per email (case-insensitive, tramite la nuova colonna `email`) invece di mostrare un elenco di slot liberi. Se l'email dell'account Google corrisponde a una riga libera, il collegamento avviene automaticamente. Se non corrisponde a nessuna riga (email non ancora nota, o nessun profilo per quella persona), l'utente vede solo un messaggio d'errore che invita a contattare un amministratore, con un pulsante per uscire e riprovare con un altro account — nessuna selezione manuale di ripiego. Le email sono popolate via migration (`m5_email_giocatori_squadra`), non tramite un'interfaccia amministrativa in questa iterazione. Il trigger `enforce_giocatori_squadra_update` (DD-016) viene esteso per richiedere anche la corrispondenza email, non solo lo slot libero: il vincolo resta nel database, non solo nella UI.
**Alternative scartate**
- Mantenere la selezione manuale come ripiego quando l'email non trova corrispondenza → scartata: vanificherebbe la garanzia "ognuno collega solo il proprio profilo" e reintrodurrebbe il rischio di scelta errata che questa decisione vuole eliminare.
- Un'interfaccia admin per scrivere l'email dei giocatori → rimandata: non necessaria finché le email arrivano da migration; si può aggiungere in futuro senza toccare questa decisione.
**Conseguenze**
- Le righe senza email nota restano bloccate — nessuno può collegarle, nemmeno per errore — finché una migration futura non la imposta. Oggi solo 2 dei 17 giocatori hanno l'email registrata.
- `slotLiberi` (funzione ed elenco "slot liberi" in `/benvenuto`) è stato rimosso: non aveva più chiamanti in produzione dopo il cambio.
- Un utente che accede con l'account Google sbagliato resta bloccato su `/benvenuto` finché non esce e riprova con l'account giusto.
**Riesame**
- Quando tutte le 17 email saranno note e verificate.
- Se in futuro serve un'assistenza admin diretta dal flusso di login invece che da `/admin`.
---
## Decisioni in valutazione
---
+4 -1
View File
@@ -40,7 +40,10 @@ responsabilità del giocatore che li fornisce.
1. Login tramite Google oppure Email. *Implementato con il solo Google: la squadra ha tutti
un account Google, e un secondo metodo è additivo (un bottone in più sulla stessa
schermata) il giorno che serve.*
2. Selezione del proprio giocatore.
2. Collegamento automatico al proprio giocatore, confrontando l'email dell'account Google
con l'email registrata in `giocatori_squadra` (DD-018). Nessuna scelta manuale: se
l'email non corrisponde a nessun profilo, l'accesso si ferma con un messaggio che invita
a contattare un amministratore.
3. Accesso alla Home.
Se il profilo non è completo compare automaticamente un widget di completamento.
+6 -1
View File
@@ -39,7 +39,12 @@ export function useSessione() {
}
}, []);
return { sessione, pronta, utenteId: sessione?.user.id ?? null };
return {
sessione,
pronta,
utenteId: sessione?.user.id ?? null,
emailUtente: sessione?.user.email ?? null,
};
}
export async function accediConGoogle(): Promise<void> {
+13 -3
View File
@@ -15,6 +15,7 @@ export type GiocatoreSquadra = {
ruolo: string;
authUserId: string | null;
attivo: boolean;
email: string | null;
};
type RigaGiocatoreSquadra = {
@@ -25,6 +26,7 @@ type RigaGiocatoreSquadra = {
ruolo: string;
auth_user_id: string | null;
attivo: boolean;
email: string | null;
};
export const SQUADRA_KEY = ["giocatori-squadra"] as const;
@@ -45,6 +47,7 @@ export function rosaFallback(): GiocatoreSquadra[] {
ruolo: g.ruolo,
authUserId: null,
attivo: true,
email: null,
}));
}
@@ -61,14 +64,20 @@ export function slotDi(
return righe.find((g) => g.authUserId === utenteId) ?? null;
}
export function slotLiberi(righe: GiocatoreSquadra[]): GiocatoreSquadra[] {
return righe.filter((g) => g.attivo && !g.authUserId);
/** Lo slot libero la cui email coincide con quella dell'account Google (case-insensitive). */
export function slotPerEmail(
righe: GiocatoreSquadra[],
email: string | null,
): GiocatoreSquadra | null {
if (!email) return null;
const cercata = email.trim().toLowerCase();
return righe.find((g) => !g.authUserId && g.email?.trim().toLowerCase() === cercata) ?? null;
}
async function fetchSquadra(): Promise<GiocatoreSquadra[]> {
const { data, error } = await supabaseNuoveTabelle
.from("giocatori_squadra")
.select("id, nome, cognome, numero, ruolo, auth_user_id, attivo")
.select("id, nome, cognome, numero, ruolo, auth_user_id, attivo, email")
.order("id");
if (error) throw error;
const righe = (data ?? []) as RigaGiocatoreSquadra[];
@@ -80,6 +89,7 @@ async function fetchSquadra(): Promise<GiocatoreSquadra[]> {
ruolo: r.ruolo,
authUserId: r.auth_user_id,
attivo: r.attivo,
email: r.email,
}));
}
+42 -60
View File
@@ -1,16 +1,14 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { LogIn } from "lucide-react";
import { LogIn, LogOut } from "lucide-react";
import { toast } from "sonner";
import { TeamLogo } from "@/components/crapp/ui-bits";
import { accediConGoogle, useSessione } from "@/lib/auth";
import { accediConGoogle, esci, useSessione } from "@/lib/auth";
import {
nomeCompleto,
slotDi,
slotLiberi,
slotPerEmail,
useCollegaGiocatore,
useGiocatoriSquadra,
type GiocatoreSquadra,
} from "@/lib/giocatori-squadra";
import { impostaGiocatore, resetGiocatore, useGiocatoreCorrente } from "@/lib/user-store";
@@ -32,43 +30,17 @@ export const Route = createFileRoute("/benvenuto")({
component: Benvenuto,
});
function Scheda({
titolo,
sottotitolo,
onClick,
iniziali,
}: {
titolo: string;
sottotitolo: string;
onClick: () => void;
iniziali: string;
}) {
return (
<button
type="button"
onClick={onClick}
className="flex w-full items-center gap-4 rounded-2xl bg-card p-4 shadow-card transition-transform active:scale-[0.98]"
>
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-secondary font-display text-lg">
{iniziali}
</div>
<div className="min-w-0 flex-1 text-left">
<p className="font-semibold leading-tight">{titolo}</p>
<p className="text-xs text-muted-foreground">{sottotitolo}</p>
</div>
</button>
);
}
function Benvenuto() {
const navigate = useNavigate();
const giocatore = useGiocatoreCorrente();
const { pronta, utenteId } = useSessione();
const { pronta, utenteId, emailUtente } = useSessione();
const { righe, daDatabase } = useGiocatoriSquadra();
const collega = useCollegaGiocatore();
const [inCorso, setInCorso] = useState(false);
const [tentato, setTentato] = useState(false);
const mioSlot = slotDi(righe, utenteId);
const slotEmail = slotPerEmail(righe, emailUtente);
// Si entra solo da loggati e con uno slot collegato (DD-011).
const puoEntrare = !!giocatore && !!utenteId;
@@ -83,6 +55,18 @@ function Benvenuto() {
else if (utenteId && daDatabase) resetGiocatore();
}, [mioSlot, utenteId, daDatabase]);
// Collegamento automatico per email (DD-018): un solo tentativo, mai su dati di
// fallback. Se fallisce o non trova corrispondenza resta lo stato d'errore, senza
// scelta manuale di ripiego.
useEffect(() => {
if (!utenteId || !daDatabase || mioSlot || !slotEmail || tentato) return;
setTentato(true);
collega
.mutateAsync({ giocatoreId: slotEmail.id, utenteId })
.then(() => impostaGiocatore(slotEmail.id))
.catch(() => toast.error("Collegamento non riuscito. Riprova o contatta un amministratore."));
}, [utenteId, daDatabase, mioSlot, slotEmail, tentato, collega]);
async function accedi() {
setInCorso(true);
try {
@@ -93,17 +77,19 @@ function Benvenuto() {
}
}
async function reclama(g: GiocatoreSquadra) {
if (!utenteId) return;
async function esciERiprova() {
try {
await collega.mutateAsync({ giocatoreId: g.id, utenteId });
impostaGiocatore(g.id);
} catch {
toast.error("Profilo già collegato a un altro account. Chiedi a un amministratore.");
await esci();
} catch (error) {
toast.error(error instanceof Error ? error.message : "Uscita non riuscita");
}
}
const liberi = slotLiberi(righe);
// Errore: email senza corrispondenza, oppure trovata ma il tentativo di collegamento
// è fallito (slot nel frattempo reclamato da altri, o errore di rete).
const erroreCollegamento =
!!utenteId && daDatabase && !mioSlot && (!slotEmail || collega.isError);
const inAttesaCollegamento = !!utenteId && !mioSlot && !erroreCollegamento;
return (
<div className="flex min-h-screen flex-col items-center justify-center px-6 py-12">
@@ -126,29 +112,25 @@ function Benvenuto() {
<LogIn className="h-4 w-4" /> Accedi con Google
</button>
</>
) : (
) : erroreCollegamento ? (
<>
<p className="mt-2 text-center text-sm text-muted-foreground">
Sei entrato. Scegli il tuo nome: resterà collegato a questo account.
Nessun profilo trovato per la tua email. Contatta un amministratore per collegare il tuo
account.
</p>
<div className="mt-8 w-full max-w-sm space-y-2">
{liberi.map((g) => (
<Scheda
key={g.id}
titolo={nomeCompleto(g)}
sottotitolo={`#${g.numero} · ${g.ruolo}`}
iniziali={`${g.nome[0] ?? ""}${g.cognome[0] ?? ""}`.toUpperCase()}
onClick={() => void reclama(g)}
/>
))}
{liberi.length === 0 ? (
<p className="rounded-2xl bg-card p-4 text-center text-sm text-muted-foreground shadow-card">
Nessun profilo libero: chiedi a un amministratore di collegarti.
</p>
) : null}
</div>
<button
type="button"
onClick={() => void esciERiprova()}
className="premi mt-8 flex w-full max-w-sm items-center justify-center gap-2 rounded-2xl bg-secondary py-3.5 text-sm font-bold uppercase shadow-card"
>
<LogOut className="h-4 w-4" /> Esci
</button>
</>
)}
) : inAttesaCollegamento ? (
<p className="mt-2 text-center text-sm text-muted-foreground">
Ti stiamo collegando al tuo profilo...
</p>
) : null}
</div>
);
}
@@ -0,0 +1,62 @@
-- M5 — Collegamento automatico per email al primo accesso (DD-018)
--
-- Sostituisce la scelta manuale del proprio slot in `/benvenuto` con un confronto tra
-- l'email dell'account Google e l'email registrata per ciascun giocatore. Additiva:
-- non tocca `profili_giocatore` né altre tabelle v1.0.
ALTER TABLE public.giocatori_squadra
ADD COLUMN email text;
COMMENT ON COLUMN public.giocatori_squadra.email IS
'Email nota in anticipo per il giocatore (non l''email personale in profili_giocatore): '
'chiave del collegamento automatico account <-> giocatore al primo accesso (DD-018). '
'NULL finché non nota: la riga resta non collegabile da nessuno.';
-- Case-insensitive, tollerante a più righe con email NULL (non ancora note).
CREATE UNIQUE INDEX giocatori_squadra_email_unica
ON public.giocatori_squadra (lower(email))
WHERE email IS NOT NULL;
-- Il trigger di DD-016 (m1_giocatori_squadra) blocca qualunque UPDATE che non arrivi da un
-- admin autenticato o dal giocatore stesso: durante la migration non c'è sessione, quindi
-- va disattivato solo per il seed e riattivato subito dopo.
ALTER TABLE public.giocatori_squadra DISABLE TRIGGER enforce_giocatori_squadra_update;
-- Solo le due email confermate finora; le altre 15 arriveranno con una migration futura.
UPDATE public.giocatori_squadra SET email = 'ivan.cacciari.1995@gmail.com' WHERE id = 'g4';
UPDATE public.giocatori_squadra SET email = 'davide3011@gmail.com' WHERE id = 'g10';
ALTER TABLE public.giocatori_squadra ENABLE TRIGGER enforce_giocatori_squadra_update;
-- Estende il trigger di DD-016: oltre a "slot libero, nessun altro campo cambia", un
-- non-admin può collegarsi solo se l'email del token coincide con l'email della riga
-- (case-insensitive). Righe senza email restano bloccate per chiunque.
CREATE OR REPLACE FUNCTION public.enforce_giocatori_squadra_update()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
IF public.has_role(auth.uid(), 'admin'::public.app_role) THEN
RETURN NEW;
END IF;
IF OLD.auth_user_id IS NULL
AND NEW.auth_user_id = auth.uid()
AND OLD.email IS NOT NULL
AND lower(OLD.email) = lower(coalesce(auth.jwt() ->> 'email', ''))
AND NEW.id IS NOT DISTINCT FROM OLD.id
AND NEW.nome IS NOT DISTINCT FROM OLD.nome
AND NEW.cognome IS NOT DISTINCT FROM OLD.cognome
AND NEW.numero IS NOT DISTINCT FROM OLD.numero
AND NEW.ruolo IS NOT DISTINCT FROM OLD.ruolo
AND NEW.attivo IS NOT DISTINCT FROM OLD.attivo
AND NEW.email IS NOT DISTINCT FROM OLD.email
AND NEW.creato_il IS NOT DISTINCT FROM OLD.creato_il THEN
RETURN NEW;
END IF;
RAISE EXCEPTION 'Aggiornamento non autorizzato su giocatori_squadra';
END;
$$;
+19 -10
View File
@@ -6,7 +6,7 @@ import {
numeroGiaUsato,
rosaFallback,
slotDi,
slotLiberi,
slotPerEmail,
validaDatiSquadra,
type GiocatoreSquadra,
} from "@/lib/giocatori-squadra";
@@ -20,6 +20,7 @@ const riga = (parziale: Partial<GiocatoreSquadra> = {}): GiocatoreSquadra => ({
ruolo: "schiacciatore",
authUserId: null,
attivo: true,
email: null,
...parziale,
});
@@ -62,17 +63,25 @@ assert.equal(slotDi(righe, "u1")?.id, "g1");
assert.equal(slotDi(righe, "u9"), null, "nessuno slot per un account non collegato");
assert.equal(slotDi(righe, null), null, "senza sessione non c'è slot");
// --- slotLiberi ----------------------------------------------------------------
const rosaMista: GiocatoreSquadra[] = [
riga({ id: "g1", attivo: true, authUserId: null }),
riga({ id: "g2", attivo: true, authUserId: "u1" }),
riga({ id: "g3", attivo: false, authUserId: null }),
// --- slotPerEmail ----------------------------------------------------------------
const rosaEmail: GiocatoreSquadra[] = [
riga({ id: "g1", email: "foo@bar.com", authUserId: null }),
riga({ id: "g2", email: "baz@qux.com", authUserId: "u1" }),
riga({ id: "g3", email: null, authUserId: null }),
];
assert.deepEqual(
slotLiberi(rosaMista).map((g) => g.id),
["g1"],
"libero solo chi è attivo e senza account collegato",
assert.equal(slotPerEmail(rosaEmail, "foo@bar.com")?.id, "g1", "match esatto");
assert.equal(
slotPerEmail(rosaEmail, "Foo@Bar.com")?.id,
"g1",
"il confronto ignora maiuscole/minuscole",
);
assert.equal(slotPerEmail(rosaEmail, null), null, "senza email non c'è match");
assert.equal(
slotPerEmail(rosaEmail, "baz@qux.com"),
null,
"uno slot già collegato non risulta in match anche con email coincidente",
);
assert.equal(slotPerEmail(rosaEmail, ""), null, "stringa vuota trattata come nessuna email");
// --- validaDatiSquadra ----------------------------------------------------------------
const datiOk = { nome: "Mario", cognome: "Rossi", numero: 7, ruolo: "schiacciatore" };
+2 -11
View File
@@ -14,7 +14,6 @@ import {
numeroGiaUsato,
rosaFallback,
slotDi,
slotLiberi,
validaDatiSquadra,
type GiocatoreSquadra,
} from "@/lib/giocatori-squadra";
@@ -112,6 +111,7 @@ const squadra: GiocatoreSquadra[] = [
ruolo: "Banda",
authUserId: null,
attivo: true,
email: null,
},
{
id: "g2",
@@ -121,6 +121,7 @@ const squadra: GiocatoreSquadra[] = [
ruolo: "Libero",
authUserId: "u2",
attivo: true,
email: null,
},
];
const csv = csvTesseramento(squadra, { g1: completo });
@@ -146,16 +147,6 @@ assert.deepEqual(dividiNome("Ivan"), { nome: "Ivan", cognome: "" });
assert.equal(slotDi(squadra, null), null, "senza sessione nessuno slot");
assert.equal(slotDi(squadra, "u2")?.id, "g2");
assert.equal(slotDi(squadra, "sconosciuto"), null);
assert.deepEqual(
slotLiberi(squadra).map((g) => g.id),
["g1"],
"uno slot già collegato non è più libero",
);
assert.deepEqual(
slotLiberi([...squadra, { ...squadra[0]!, id: "g3", attivo: false }]).map((g) => g.id),
["g1"],
"i giocatori non attivi restano fuori",
);
// --- dati squadra modificabili dall'admin (DD-017) ---------------------------
const datiOk = { nome: "Ivan", cognome: "Cacciari", numero: 23, ruolo: "Banda" };