Fasi e politiche del programma
Questo documento definisce il modello di dominio che EC usa per descrivere a quale programma sta partecipando un contributor e quali regole economiche e operative si applicano a lui. Si integra con:
- Persone e ruoli (guida ops) — catalogo dei ruoli operatore e player
- Link di identità con l'app Playroll — come un contributor viene collegato a un utente dell'app Playroll
- Catalogo Fase 1 — i titoli abilitati per la cattura nella Fase 1
Il modello è implementato sul progetto Supabase playroll_contributor_portal (il progetto legale/pagamenti), con edge function cross-project che leggono lo stato dei ruoli dal progetto playroll_supa_new (il progetto app/recording).
Perché questo modello esiste
Finora EC ha gestito un'unica "fase" implicita — il pilot della Fase 1 — con soglie di pagamento hardcoded ($10 / 60h / $600 in network-crm/client/src/pages/p2e-funnel.tsx) che non corrispondevano alla politica operativa negoziata per il pilot (€500 / 90min / 90min / 10h / 10h, approvazione manuale).
Quello scarto è accettabile finché esiste una sola fase. Smette di esserlo quando:
- La politica del pilot deve coesistere con fasi future (loop di retention, ondate di onboarding B2B, coorti provenienti da partner).
- Gli operatori (Phil, Ale) hanno bisogno di un unico posto dove chiedere "qual è la regola per questa persona, adesso?" senza leggere il codice sorgente.
- La storia delle compensazioni deve restare verificabile: un pagamento effettuato secondo le regole della Fase 1 deve rimanere leggibile anche dopo che la Fase 2 ha sostituito la politica attiva.
Il modello qui sotto disaccoppia tre aspetti che in precedenza erano intrecciati:
- Quale programma è attivo (
program_phases) - Quale politica economica si applica all'interno di quel programma (
payment_policiese affini, tutti in FK verso una fase) - A quale gruppo operativo appartiene un contributor (
cohorts)
Modello concettuale
Una fase del programma è un periodo di operazioni definito da EC con le proprie regole. È un'entità di prima classe. Ogni fase aggrega un insieme di configurazioni con scope di fase, di cui la politica di pagamento è una. Le configurazioni descrivono tutto ciò che può variare tra le fasi:
program_phase
├── payment_policy (economic rules: tranches, basis, approval mode)
├── eligibility_rules (which player roles can participate)
├── validation_config (manual vs automated pipeline)
├── recorder_config (minimum app version, identity link required)
├── notification_config (which notification categories are active)
└── ... (extend by adding a new phase_*_config table — never jsonb)
Una coorte è un raggruppamento operativo ortogonale alla fase. Due contributor nella stessa coorte possono appartenere a fasi diverse nel tempo, e una fase di solito contiene più coorti (ad esempio pioneers e staff entrambe all'interno della fase pilot).
Un contributor appartiene a esattamente una fase del programma alla volta (contributors.phase_id) e a zero o più coorti (contributor_cohorts, M:N).
Tre assi di identità ortogonali
Quando si ragiona su una persona nel sistema EC, tieni questi tre assi separati. Vivono in tabelle diverse e rispondono a domande diverse.
| Asse | Domanda | Dove vive | Valori odierni |
|---|---|---|---|
| Ruolo CRM | "Cosa possono fare nel Control Plane?" | playroll_supa_new.crm_user_roles (chiave su email) | admin, cm, vc, sales |
| Ruolo player | "Cosa possono fare nell'app Playroll?" | playroll_supa_new.player_roles (chiave su user_id) e playroll_supa_new.steam_id_allowlist (chiave su steam_id, pre-login) | staff, pioneer, tester |
| Fase + Coorte | "Quali regole di programma si applicano a loro come contributor?" | playroll_contributor_portal.contributors.phase_id + playroll_contributor_portal.contributor_cohorts | fase: pilot · coorti: pioneers, staff |
Un singolo essere umano avrà tipicamente al massimo un ruolo CRM (se fa parte dello staff EC), un ruolo player (se ha un account Playroll) e una fase più una o più coorti (se è un contributor). I tre insiemi non si sovrappongono per identità (chiavi diverse, sistemi RBAC diversi), anche quando la stessa persona abbraccia tutti e tre (ad esempio Marco è admin lato CRM, staff lato player e non è affatto un contributor).
La parola pioneer compare sia come ruolo player (singolare, utente individuale) sia come codice di coorte pioneers (plurale, gruppo). Sono intenzionalmente lo stesso concetto visto da due lati — uno nell'app, uno nel portale. Tratta singolare e plurale come un segnale deliberato di quale sistema stai guardando.
Schema
Tutte le tabelle vivono su playroll_contributor_portal (ref Supabase xlsjwlmsqonllvtyplml).
program_phases
L'entità di fase di prima classe. Una riga per ciascuna fase del programma che EC ha eseguito, sta eseguendo o ha pianificato.
| Colonna | Tipo | Note |
|---|---|---|
id | uuid PK | |
code | text UNIQUE | Identificatore semantico: pilot, retention_v1, b2b_pilot_q3. Evita codici numerici come phase1 / phase2 — invecchiano male. |
label | text | Leggibile dall'uomo: "Phase 1 Pilot" |
description | text | Riepilogo di un paragrafo |
default_for_new_contributors | boolean | Esattamente una riga può avere questo impostato a true (indice parziale univoco). Guida l'assegnazione automatica dei nuovi contributor che superano i controlli di idoneità. |
starts_at, ends_at | timestamptz | Opzionali. Solo audit — non condizionano l'idoneità. |
notes | text | |
created_at, updated_at | timestamptz |
Vincolo: un indice parziale univoco garantisce al massimo una fase di default in qualunque momento:
create unique index one_default_phase
on program_phases (default_for_new_contributors)
where default_for_new_contributors = true;
hour_basis_types (catalogo)
Il glossario canonico di "cosa significa un'ora". Elimina l'ambiguità tra duration_seconds, validated_hours, rewardable_seconds e le altre sette varianti che si trovano in giro per il codebase.
| Colonna | Tipo | Note |
|---|---|---|
code | text PK | recorded_minutes, uploaded_minutes, validated_minutes |
label | text | Nome visualizzato |
description | text | Cosa rappresenta realmente il valore |
source_table | text | Da dove viene calcolato |
source_column | text | Quale colonna viene sommata/contata |
aggregation | text CHECK | sum, count, max, min, avg |
unit | text CHECK | seconds, minutes, hours, count |
Seed:
| code | source_table | source_column | aggregation | unit |
|---|---|---|---|---|
recorded_minutes | recording_sessions (progetto app) | duration_seconds | sum | seconds |
uploaded_minutes | recording_sessions (progetto app, filtrato alle sessioni con ≥1 upload riuscito) | duration_seconds | sum | seconds |
validated_minutes | session_validation_results (progetto portale) | validated_seconds | sum | seconds |
payment_policies.basis_code ha una FK verso questa tabella.
payment_policies
La politica economica per una fase. Una riga per fase (1:1 oggi; il vincolo univoco può essere allentato se in futuro serve A/B testing).
| Colonna | Tipo | Note |
|---|---|---|
id | uuid PK | |
phase_id | uuid FK UNIQUE → program_phases(id) ON DELETE CASCADE | 1:1 con la fase |
total_eur | numeric | Tetto per contributor, ad esempio 500 per la Fase 1 |
basis_code | text FK → hour_basis_types(code) | Fonte di verità su quale "ora" viene misurata |
approval_mode | text CHECK (manual, auto, hybrid) | Il pilot è manual. Consente espansioni future senza eliminare il booleano. |
capacity_eur | numeric NULL | Tetto di budget a livello di programma opzionale (separato dal tetto per contributor) |
notes | text |
payment_policy_tranches
Gli step di ricompensa all'interno di una politica. Memorizzati in forma incrementale (threshold_delta_minutes) per rispecchiare il modo in cui il PM li descrive ("primi 90 minuti... secondi 90 minuti... prossime 10 ore"). Una view espone le soglie cumulative per comodità di interrogazione.
| Colonna | Tipo | Note |
|---|---|---|
id | uuid PK | |
policy_id | uuid FK → payment_policies(id) ON DELETE CASCADE | |
idx | int | Ordine all'interno della politica (UNIQUE con policy_id) |
label | text | "Trust 1", "Block 1" |
threshold_delta_minutes | int | Incremento rispetto alla tranche precedente |
eur | numeric | Importo sbloccato quando questa tranche viene raggiunta |
View di supporto:
create view payment_policy_tranches_cumulative_v as
select
t.*,
sum(t.threshold_delta_minutes) over (
partition by t.policy_id
order by t.idx
rows between unbounded preceding and current row
) as cumulative_threshold_minutes
from payment_policy_tranches t;
cohorts (catalogo)
Il catalogo delle coorti con nome. Previene la proliferazione di coorti dovuta a refusi (pioneer vs pioneers vs Pioneers).
| Colonna | Tipo | Note |
|---|---|---|
code | text PK | pioneers, staff, futuro: b2b_recruit_q3, ... |
label | text | Nome visualizzato |
description | text | Cosa rappresenta questa coorte |
created_at | timestamptz |
Seed: pioneers ("Prima ondata di giocatori selezionati a mano per il lancio del pilot") e staff ("Membri interni del team EC che partecipano come contributor per il dogfooding").
contributor_cohorts
M:N tra contributor e coorti. Un contributor può appartenere a più coorti nel tempo.
| Colonna | Tipo | Note |
|---|---|---|
contributor_id | uuid FK → contributors(id) ON DELETE CASCADE | |
cohort_code | text FK → cohorts(code) | |
joined_at | timestamptz default now() | |
left_at | timestamptz NULL | NULL = ancora nella coorte |
| PK | (contributor_id, cohort_code) |
phase_eligibility_rules
Quali valori di player_role sono ammessi in una fase. Semantica OR: un contributor è idoneo se il suo ruolo player corrisponde ad almeno una riga della fase.
| Colonna | Tipo | Note |
|---|---|---|
id | uuid PK | |
phase_id | uuid FK → program_phases(id) ON DELETE CASCADE | |
allowed_player_role | text | Corrisponde a playroll_supa_new.player_role_catalog.role |
max_contributors | int NULL | NULL = illimitato |
| UNIQUE | (phase_id, allowed_player_role) |
Per il pilot della Fase 1: due righe, (pilot, pioneer, 15) e (pilot, staff, NULL).
phase_validation_config
Come funziona la validazione delle sessioni all'interno di una fase. Una riga per fase.
| Colonna | Tipo | Note |
|---|---|---|
phase_id | uuid UNIQUE FK → program_phases(id) ON DELETE CASCADE | |
pipeline | text CHECK (manual, auto_v1, auto_v2) | Il pilot usa manual finché la pipeline di validazione non viene rilasciata |
validation_pct_required | int NULL | Soglia per la pipeline di QA quando attiva |
notes | text |
phase_recorder_config
Requisiti lato app per partecipare a una fase.
| Colonna | Tipo | Note |
|---|---|---|
phase_id | uuid UNIQUE FK → program_phases(id) ON DELETE CASCADE | |
min_recorder_version | text NULL | Blocca le versioni del recorder inferiori a questa |
requires_steam_link | boolean default true | Se il contributor debba avere una riga contributor_app_links verificata per essere conteggiato |
notes | text |
phase_notification_config
Quali categorie di notifica sono abilitate per una fase. M:N (una riga per categoria/sottotipo).
| Colonna | Tipo | Note |
|---|---|---|
id | uuid PK | |
phase_id | uuid FK → program_phases(id) ON DELETE CASCADE | |
category | text | ad esempio cohort_announcement, payment_readiness |
subtype | text NULL | |
t1_throttle, t2_throttle | int NULL | Tetti su finestra di 7 giorni |
| UNIQUE | (phase_id, category, subtype) |
Vuota nella Fase 1 (ancora nessun throttling per fase) — la tabella viene aggiunta ora in modo che le fasi future possano agganciare configurazione senza una modifica di schema.
Cablaggio del contributor
alter table contributors add column phase_id uuid references program_phases(id);
Un contributor con phase_id IS NULL è registrato ma non ancora abilitato per alcuna fase — tipico di chi ha completato l'onboarding del portale ma la cui identità Steam non corrisponde ancora alle regole di idoneità di nessuna fase. Non può registrare e non può guadagnare finché un admin (o l'assegnazione automatica nella edge function) non imposta la fase.
Storia delle compensazioni: congelamento alla creazione
alter table compensation_items add column policy_id uuid references payment_policies(id);
Ogni riga di compensation_items registra quale politica era in vigore quando è stata creata. Questo rende la storia dei pagamenti stabile per l'audit: anche dopo che un contributor è stato promosso a una fase successiva, i vecchi item restano attribuiti alla politica che li ha prodotti. Nessun ricalcolo retroattivo.
L'immutabilità è imposta da un trigger, non per convenzione:
create or replace function prevent_compensation_policy_change()
returns trigger language plpgsql as $$
begin
if old.policy_id is not null and new.policy_id is distinct from old.policy_id then
raise exception 'compensation_items.policy_id is immutable after creation (item_id=%)', old.id;
end if;
return new;
end $$;
create trigger compensation_items_policy_immutable
before update on compensation_items
for each row execute function prevent_compensation_policy_change();
Assegnazione della fase al momento del link
Quando il link contributor↔app viene stabilito (entrambi i flussi descritti in identity-link.md), la edge function esegue la routine di assegnazione qui sotto.
Regole di assegnazione (Fase 1)
steam_id_allowlist.role | phase_id | contributor_cohorts.code | Esito |
|---|---|---|---|
pioneer | pilot | pioneers | Link riuscito, contributor attivo |
staff | pilot | staff | Link riuscito, contributor attivo |
tester | — | — | Link rifiutato — l'ondata 2 non è ancora attivata per il pilot |
| non in allowlist | — | — | Link rifiutato — steam_id_not_allowlisted |
L'allowlist è il gate per la Fase 1. Verrà dismessa in una fase successiva, una volta che l'idoneità sarà determinata da altri criteri (ad esempio accettazione di una candidatura, coorti provenienti da partner). Quando ciò accadrà, la routine di assegnazione passerà da "leggi l'allowlist" a "valuta phase_eligibility_rules rispetto al player_role del contributor" — stessa forma, fonte diversa.
Transizioni tra fasi
La fase è per contributor, non globale. Promuovere un contributor da pilot a una fase futura è un'azione amministrativa esplicita:
- Aggiorna
contributors.phase_idalla nuova fase. - Inserisci righe
contributor_cohortsper eventuali nuove assegnazioni di coorte. La vecchia riga di coorte dovrebbe impostareleft_at. - La promozione scrive su
audit_events. - Gli item di compensazione già creati restano agganciati al loro
policy_idoriginale (immutabile, vedi sopra).
Regole di promozione automatica (ad esempio "se tutte e 4 le tranche del pilot sono sbloccate, promuovi a retention") potranno essere aggiunte più avanti come funzione schedulata. Per la Fase 1 la promozione è manuale.
Riepilogo delle decisioni
| Decisione | Motivazione |
|---|---|
program_phases come entità di prima classe | Evita di sovraccaricare "fase" con il ciclo di vita del contributor o le ondate di marketing. La fase è il programma. |
Configurazioni di fase come tabelle separate con chiave phase_id, non colonne jsonb | Aggiungere un nuovo aspetto (missioni, classifiche) significa aggiungere una nuova tabella phase_*_config. Niente jsonb che cresce senza tipo. |
| Coorte + fase come assi distinti | Una coorte è operativa (raggruppamento); una fase è contrattuale (regole). Confonderle blocca il modello. |
cohorts come tabella di catalogo con FK | Elimina la proliferazione di stringhe di coorte dovuta a refusi. |
hour_basis_types come catalogo | I 9 concetti di tipo-ora nel codebase collassano in 3 tipi canonici con source_table / source_column espliciti. |
| Tranche memorizzate in forma incrementale + view cumulativa | Corrisponde al modo in cui il business le descrive; la forma cumulativa è a una window function di distanza. |
Booleano default_for_new_contributors con indice parziale univoco | "Attiva" è ambiguo (due fasi possono essere live in parallelo per coorti diverse). "Default per i nuovi contributor" è la domanda a cui la routine di assegnazione deve effettivamente rispondere. |
| Assegnazione della fase tramite edge function, non trigger di DB | Visibile nei log, debuggabile, testabile, vive accanto ai flussi di link esistenti. I trigger nascosti nel DB sono facili da dimenticare. |
compensation_items.policy_id immutabile tramite trigger | Storia di grado legale. Il trigger è difensivo; la sola convenzione non basta a questo livello di rischio di audit. |
approval_mode come text (non boolean) | Modalità ibride future ("auto per importi sotto €X, manuale sopra") rientrano senza eliminare la colonna. |
| Le letture dell'allowlist restano nella routine di assegnazione | L'allowlist verrà dismessa dopo la Fase 1. Centralizzare la lettura dell'allowlist in un unico posto rende la futura migrazione all'assegnazione basata solo su phase_eligibility_rules una modifica a un solo file. |
Questioni aperte rinviate a una fase successiva
Queste non bloccano la Fase 1 e sono esplicitamente fuori dallo scope della migrazione iniziale:
- Promozione automatica di fase (edge function cron che promuove i contributor che completano tutte le tranche).
- Throttling delle notifiche per fase (la tabella esiste, nessuna riga nei seed della Fase 1).
- A/B testing di due politiche di pagamento all'interno della stessa fase (allenterebbe il vincolo
payment_policies.phase_id UNIQUE). - Gerarchia di coorti (coorti padre/figlio). Oggi le coorti sono piatte.
Migrazioni correlate
Il modello è consegnato in quattro migrazioni sotto playroll-contributor-portal/supabase/migrations/:
- M1 —
program_phases,hour_basis_types,payment_policies,payment_policy_tranches+ view cumulativa. Esegue il seed solo dihour_basis_types. - M2 —
cohorts(catalogo con seedpioneersestaff),phase_eligibility_rules,phase_validation_config,phase_recorder_config,phase_notification_config. - M3 —
contributors.phase_id,contributor_cohorts,compensation_items.policy_idcon trigger di immutabilità. - M4 — Seed della fase
pilot: riga program_phase, payment_policy con tetto di €500 e basisrecorded_minutes, 4 tranche (90/90/600/600 delta minuti, 50/50/200/200 EUR), regole di idoneità perpioneerestaff, validazione manuale, recorder che richiede il link Steam. Backfill dei 5 contributor esistenti alla fasepilote alla coortestaff.
L'aggiornamento delle edge function (activate-contributor-app-link e redeem-activation-code) segue in una modifica separata per implementare la routine di assegnazione descritta sopra.