Passa al contenuto principale

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:

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:

  1. La politica del pilot deve coesistere con fasi future (loop di retention, ondate di onboarding B2B, coorti provenienti da partner).
  2. Gli operatori (Phil, Ale) hanno bisogno di un unico posto dove chiedere "qual è la regola per questa persona, adesso?" senza leggere il codice sorgente.
  3. 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_policies e 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.

AsseDomandaDove viveValori 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_cohortsfase: 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).

Nota sulla nomenclatura

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.

ColonnaTipoNote
iduuid PK
codetext UNIQUEIdentificatore semantico: pilot, retention_v1, b2b_pilot_q3. Evita codici numerici come phase1 / phase2 — invecchiano male.
labeltextLeggibile dall'uomo: "Phase 1 Pilot"
descriptiontextRiepilogo di un paragrafo
default_for_new_contributorsbooleanEsattamente 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_attimestamptzOpzionali. Solo audit — non condizionano l'idoneità.
notestext
created_at, updated_attimestamptz

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;

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.

ColonnaTipoNote
codetext PKrecorded_minutes, uploaded_minutes, validated_minutes
labeltextNome visualizzato
descriptiontextCosa rappresenta realmente il valore
source_tabletextDa dove viene calcolato
source_columntextQuale colonna viene sommata/contata
aggregationtext CHECKsum, count, max, min, avg
unittext CHECKseconds, minutes, hours, count

Seed:

codesource_tablesource_columnaggregationunit
recorded_minutesrecording_sessions (progetto app)duration_secondssumseconds
uploaded_minutesrecording_sessions (progetto app, filtrato alle sessioni con ≥1 upload riuscito)duration_secondssumseconds
validated_minutessession_validation_results (progetto portale)validated_secondssumseconds

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).

ColonnaTipoNote
iduuid PK
phase_iduuid FK UNIQUE → program_phases(id) ON DELETE CASCADE1:1 con la fase
total_eurnumericTetto per contributor, ad esempio 500 per la Fase 1
basis_codetext FK → hour_basis_types(code)Fonte di verità su quale "ora" viene misurata
approval_modetext CHECK (manual, auto, hybrid)Il pilot è manual. Consente espansioni future senza eliminare il booleano.
capacity_eurnumeric NULLTetto di budget a livello di programma opzionale (separato dal tetto per contributor)
notestext

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.

ColonnaTipoNote
iduuid PK
policy_iduuid FK → payment_policies(id) ON DELETE CASCADE
idxintOrdine all'interno della politica (UNIQUE con policy_id)
labeltext"Trust 1", "Block 1"
threshold_delta_minutesintIncremento rispetto alla tranche precedente
eurnumericImporto 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;

Il catalogo delle coorti con nome. Previene la proliferazione di coorti dovuta a refusi (pioneer vs pioneers vs Pioneers).

ColonnaTipoNote
codetext PKpioneers, staff, futuro: b2b_recruit_q3, ...
labeltextNome visualizzato
descriptiontextCosa rappresenta questa coorte
created_attimestamptz

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.

ColonnaTipoNote
contributor_iduuid FK → contributors(id) ON DELETE CASCADE
cohort_codetext FK → cohorts(code)
joined_attimestamptz default now()
left_attimestamptz NULLNULL = 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.

ColonnaTipoNote
iduuid PK
phase_iduuid FK → program_phases(id) ON DELETE CASCADE
allowed_player_roletextCorrisponde a playroll_supa_new.player_role_catalog.role
max_contributorsint NULLNULL = 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.

ColonnaTipoNote
phase_iduuid UNIQUE FK → program_phases(id) ON DELETE CASCADE
pipelinetext CHECK (manual, auto_v1, auto_v2)Il pilot usa manual finché la pipeline di validazione non viene rilasciata
validation_pct_requiredint NULLSoglia per la pipeline di QA quando attiva
notestext

phase_recorder_config

Requisiti lato app per partecipare a una fase.

ColonnaTipoNote
phase_iduuid UNIQUE FK → program_phases(id) ON DELETE CASCADE
min_recorder_versiontext NULLBlocca le versioni del recorder inferiori a questa
requires_steam_linkboolean default trueSe il contributor debba avere una riga contributor_app_links verificata per essere conteggiato
notestext

phase_notification_config

Quali categorie di notifica sono abilitate per una fase. M:N (una riga per categoria/sottotipo).

ColonnaTipoNote
iduuid PK
phase_iduuid FK → program_phases(id) ON DELETE CASCADE
categorytextad esempio cohort_announcement, payment_readiness
subtypetext NULL
t1_throttle, t2_throttleint NULLTetti 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();

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.rolephase_idcontributor_cohorts.codeEsito
pioneerpilotpioneersLink riuscito, contributor attivo
staffpilotstaffLink riuscito, contributor attivo
testerLink rifiutato — l'ondata 2 non è ancora attivata per il pilot
non in allowlistLink rifiutatosteam_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:

  1. Aggiorna contributors.phase_id alla nuova fase.
  2. Inserisci righe contributor_cohorts per eventuali nuove assegnazioni di coorte. La vecchia riga di coorte dovrebbe impostare left_at.
  3. La promozione scrive su audit_events.
  4. Gli item di compensazione già creati restano agganciati al loro policy_id originale (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

DecisioneMotivazione
program_phases come entità di prima classeEvita 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 jsonbAggiungere un nuovo aspetto (missioni, classifiche) significa aggiungere una nuova tabella phase_*_config. Niente jsonb che cresce senza tipo.
Coorte + fase come assi distintiUna coorte è operativa (raggruppamento); una fase è contrattuale (regole). Confonderle blocca il modello.
cohorts come tabella di catalogo con FKElimina la proliferazione di stringhe di coorte dovuta a refusi.
hour_basis_types come catalogoI 9 concetti di tipo-ora nel codebase collassano in 3 tipi canonici con source_table / source_column espliciti.
Tranche memorizzate in forma incrementale + view cumulativaCorrisponde 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 DBVisibile 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 triggerStoria 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 assegnazioneL'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/:

  • M1program_phases, hour_basis_types, payment_policies, payment_policy_tranches + view cumulativa. Esegue il seed solo di hour_basis_types.
  • M2cohorts (catalogo con seed pioneers e staff), phase_eligibility_rules, phase_validation_config, phase_recorder_config, phase_notification_config.
  • M3contributors.phase_id, contributor_cohorts, compensation_items.policy_id con trigger di immutabilità.
  • M4 — Seed della fase pilot: riga program_phase, payment_policy con tetto di €500 e basis recorded_minutes, 4 tranche (90/90/600/600 delta minuti, 50/50/200/200 EUR), regole di idoneità per pioneer e staff, validazione manuale, recorder che richiede il link Steam. Backfill dei 5 contributor esistenti alla fase pilot e alla coorte staff.

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.