User Flows
The contributor portal has two entry paths:
- public application at the root URL
- direct invite or referral onboarding through
?invite=CODEor?ref=CODE - Playroll app identity linking through
?link_intent=<signed-token>
Both paths rely on Supabase email verification. The verified Supabase session email is the contributor identity used by the portal and, later, by recorder access checks.
Public Application
Open public page
Enter email
Receive Supabase email link
Open latest link
Submit application
Application row is written to public.applications
Return later with same email to read latest application state
Collected application fields:
legal_name
verified email
city
university or group
contact handle
weekly availability
notes
privacy notice acknowledgement
Validation and normalization:
citymust match the local ISTAT comuni catalog.universityis normalized against the local MUR/USTAT catalog when possible.- Free-text groups or unmatched institutions are accepted as unnormalized metadata.
- Application insert requires an authenticated Supabase user whose verified email matches the submitted email.
Invite Or Referral Onboarding
Invite and referral URLs open the onboarding workspace directly:
/?invite=CODE
/?ref=CODE
If the contributor is not authenticated, they can fill the form first. On submit, the browser saves the draft locally, sends the verification email, and only writes to Supabase after the contributor returns with a verified session.
Onboarding
The onboarding workspace is grouped into:
1. Profile and contact
2. Documents and declarations
3. Recorder access identity
Collected pre-recorder fields:
legal_name
verified email
city
contact_handle
tax_status
accepted_documents
document_acceptance_evidence
The visible flow does not collect payment or high-sensitivity identity data at this stage.
Document Acceptance
The app generates text documents from the current form data:
- lettera di incarico
- privacy acknowledgement
- fiscal declaration and ISEE notice
Acceptance creates evidence in public.onboarding_submissions.document_acceptance_evidence:
package_version
accepted_at
auth_user_id
verified_email
accepted_items.incarico.document_version
accepted_items.incarico.document_hash_sha256
accepted_items.privacy.document_version
accepted_items.privacy.document_hash_sha256
accepted_items.isee_notice.document_version
accepted_items.isee_notice.document_hash_sha256
accepted_items.compensation.document_version
accepted_items.compensation.document_hash_sha256
After a checkbox is accepted, fields that would change the rendered text are locked until the relevant acceptance is unchecked. For example, tax status cannot be changed while the fiscal declaration and ISEE acknowledgement are accepted.
Rewards And Receiving Method
After onboarding is submitted, the contributor lands in the rewards workspace. The three metrics are read per contributor from the public.contributor_dashboard view; nothing is hard-coded.
Tempo validato := dashboard.validated_seconds
Ricompense sbloccate := dashboard.unlocked_eur
Cap rimanente := dashboard.remaining_cap_eur (== cap_limit_eur − fulfilled_eur)
contributor_dashboard aggregates:
playroll_time_ledger.seconds_deltawherestatus = 'validated'(sum)contributor_milestone_unlocksjoined withreward_milestones, summingreward_value_eurforstatus in ('unlocked','fulfilled')(unlocked) and'fulfilled'only (paid out)- the most recent
cap_snapshotsrow (rolling 12-month total, status) contributors.cap_limit_eur/cap_warning_eurfor the contributor row
The view is security_invoker, so RLS on the underlying tables applies — a contributor only sees their own row.
The rewards page also collects a receiving method so the contributor is ready before the first transfer:
- automatic path: own-name SEPA IBAN
- fallback path: manual review request
For SEPA IBAN, the browser validates format, country length, and checksum before insert. Supabase also normalizes and validates IBAN with database functions and stores iban_last4.
The method is not operational until email confirmation succeeds:
status = confirmed
email_confirmation_status = confirmed
Manual review rows keep the withdrawal blocked until an ops/payment system approves a supported alternative outside this repo.
Sequence
1. User clicks "Salva metodo" on the IBAN form
→ recordEvent payout.receiving_method.submitted (web)
2. Client-side validate IBAN (length, country, checksum)
→ on failure: recordEvent payout.receiving_method.rejected
3. RPC payout_iban_usage to detect duplicate IBAN across profiles
4. INSERT into payout_receiving_methods with status='pending_email_confirmation'
→ recordEvent payout.receiving_method.persisted
5. RPC mint_payout_confirmation_token(p_method_id) → returns a single-use
64-char token bound to (auth.uid(), row id, pending status), valid 24h
6. signInWithOtp(verifiedEmail, intent=payout, redirect=?payout_confirm_token=<token>)
→ recordEvent auth.otp.requested, then auth.otp.sent or auth.otp.failed
7. User opens email link → verifyOtp callback
→ RPC confirm_payout_receiving_method_with_token(p_token) flips status to
'confirmed' in one SECURITY DEFINER transaction (status, email_confirmation_status,
email_confirmed_at) and clears the token
→ recordEvent payout.receiving_method.confirmed
8. If a link_intent is still on the URL, the dedup-guarded
syncContributorAppLink fires exactly once.
Read-Only Document Review
From the rewards workspace, a contributor can reopen accepted documents in read-only mode. They can copy or download the rendered text, but cannot edit profile fields, change tax status, uncheck acceptances, or resubmit the signing form.
Playroll App Link
The app link is optional at entry time but required before the Playroll app can show the contributor as verified.
App Starts The Flow
Contributor is logged into Playroll app
App creates signed link intent (HS256 JWT, shared secret)
App opens portal with ?link_intent=<signed-token>
Contributor logs into portal
Portal captures pending link if activation gate is incomplete
Contributor completes documents and confirms SEPA IBAN
Portal activates link and syncs app projection
App refreshes on focus and shows Verified
The app cannot mark the contributor verified locally. It must wait for contributor_status_projection on the Playroll app project (vtyolotvuvlbvqbgbzde).
Activation call: one per session
The portal calls activate-contributor-app-link exactly once per intent nonce. The hook tracks processed nonces in memory and a single-in-flight promise to coalesce concurrent calls during boot, payout confirmation, and onboarding completion. Telemetry will show:
app_link.activate.requested (web) → 1
app_link.activate.received (edge) → 1
app_link.activate.verified (web + edge) → 1
A query that returns more than one app_link.activate.requested per session_id is a regression — see Telemetry.
Portal Starts The Flow (activation code)
When the contributor signed up on the portal first (e.g. landing, Discord, marketing) and only later installs the app, there is no app session for the portal to push to. The portal mints a short-lived activation code and the contributor pastes it in the app.
Contributor signs up on portal, completes documents, confirms SEPA IBAN
Portal Rewards section shows "Hai installato l'app PlayRoll? Genera un codice"
Contributor clicks "Genera codice"
Portal mint-activation-code returns an 8-char code (e.g. ZJ929AEY) with 15 min TTL
Portal UI shows the code formatted as "ZJ92 9AEY" plus a countdown
Contributor opens Playroll app, signs in with Steam
Contributor goes to Settings -> Account contributor
Contributor pastes the code and clicks Riscatta
App calls redeem-activation-code on the app project
Edge marks the code redeemed, flips contributor_app_links to active, upserts contributor_status_projection
App refreshes within ~1s, badge shows VERIFIED, the section in Settings disappears
Code spec: 8 chars, alphabet A-H J K M N P-Z 2-9 (excludes 0/1/I/L/O for legibility), single-use, SHA-256 hashed at rest, TTL 15 minutes. The raw code is shown to the contributor exactly once.
Telemetry to verify a portal-first session went well (one row per stage):
select occurred_at, event_name, attrs, error_code
from public.telemetry_events
where session_id = '<the portal session_id>'
and event_name like 'recorder_code.%'
order by occurred_at;
Expected sequence: recorder_code.requested -> recorder_code.minted -> recorder_code.displayed -> recorder_code.copy_clicked -> recorder_code.redeemed. Anything *_failed carries an error_code matching the redeem function's typed error map (see Identity Link).
The bridge is not email matching. It still requires both an authenticated app user (Steam) and an authenticated portal user (email magic link); the activation code is the proof that both have happened — just decoupled in time.