Skip to main content

Team Dashboard — Database Schema

This document describes the Supabase/PostgreSQL schema that powers the Team Dashboard at routes like:

  • /{primaryTeam}/dashboard (e.g. http://localhost:3000/super-admin/dashboard)

High-level data flow​

flowchart TB
subgraph sources["Source tables"]
SIG["signatures"]
RATES["rates"]
TXN["transactions"]
CIRC["circulators"]
CAMP["campaigns"]
TEAMS["teams"]
end

subgraph pipeline["DB pipeline"]
TRIG["Stale-marking triggers"]
RECOMP["recompute_circulator_earnings()"]
CRON["pg_cron: recompute_stale_earnings()"]
RPC["refresh_my_earnings() RPC"]
end

ROLLUP["circulator_earnings<br/>(rollup table)"]

subgraph app["Next.js app"]
SVC["getCirculatorEarnings()"]
UI["CirculatorDashboard"]
end

SIG --> TRIG
RATES --> TRIG
TXN --> TRIG
TRIG --> ROLLUP
RECOMP --> ROLLUP
CRON --> RECOMP
RPC --> RECOMP

SIG --> RECOMP
RATES --> RECOMP
TXN --> RECOMP
CIRC --> RECOMP

ROLLUP --> SVC
CAMP --> SVC
SVC --> UI

Batch identity: Every earnings row is keyed by (circulator, campaign, team, turn_in_date).


Entity relationship diagram​

erDiagram
teams ||--o{ circulator_earnings : "team"
circulators ||--o{ circulator_earnings : "circulator"
campaigns ||--o{ circulator_earnings : "campaign"
circulators ||--o{ signatures : "circulator"
teams ||--o{ signatures : "team"
campaigns ||--o{ signatures : "campaign"
teams ||--o{ rates : "team"
campaigns ||--o{ rates : "campaign"
users ||--o{ transactions : "circulator"
teams ||--o{ transactions : "team"
campaigns ||--o{ transactions : "campaign"
circulators ||--o| users : "user_id"
teams ||--o{ campaign_team_circulators : "team"
campaigns ||--o{ campaign_team_circulators : "campaign"
circulators ||--o{ campaign_team_circulators : "circulator"

circulator_earnings {
uuid circulator PK
text campaign PK
text team PK
date turn_in_date PK
int total_signatures
int valid_signatures
float validity_rate
float rate_applied
bool incentive_met
float amount_earned
float amount_paid
float kitty
float balance
timestamptz last_calculated_at
bool is_stale
}

signatures {
uuid id PK
uuid circulator FK
text campaign FK
text team FK
date turnInDate
text status
}

rates {
uuid id PK
text team FK
text campaign FK
float rate
float incentive_threshold
date startDate
date endDate
bool current
}

transactions {
uuid id PK
uuid circulator FK
text campaign FK
text team FK
date turnInDate
float totalAmount
float amountPaid
float kitty
}

circulators {
uuid id PK
uuid user_id FK
text firstName
text lastName
}

campaigns {
text id PK
text campaignName
text status
}

teams {
text id PK
text name
}

Primary read table: circulator_earnings​

Rollup table — one row per circulator turn-in batch. Populated by DB functions, not written directly by the app.

Migration: supabase/migrations/20260606083135_circulator_calculations.sql
Reference snippet: supabase/snippets/circulator_earnings.sql

ColumnTypeDescription
circulatoruuidFK → circulators.id
campaigntextFK → campaigns.id
teamtextFK → teams.id
turn_in_datedateMatches signatures."turnInDate"
total_signaturesintegerSubmitted count (status != 'invalid')
valid_signaturesintegerValid count (status = 'valid')
validity_ratedouble precisionvalid / submitted × 100
rate_applieddouble precision$ per signature from rates
incentive_metbooleanvalidity_rate >= incentive_threshold
amount_earneddouble precisionSee Earnings formula
amount_paiddouble precisionSum of transactions."amountPaid"
kittydouble precisionSum of transactions.kitty
balancedouble precisionamount_earned - amount_paid - kitty
last_calculated_attimestamptzWhen the batch was last recomputed
is_stalebooleantrue = pending recompute

Primary key / unique index: (circulator, campaign, team, turn_in_date)

Indexes:

  • idx_circulator_earnings_circulator — filter by circulator (dashboard query)
  • idx_circulator_earnings_campaign_team — filter by campaign + team
  • idx_circulator_earnings_stale — partial index where is_stale = true

RLS: SELECT allowed for authenticated users (permissive policy). Writes happen via SECURITY DEFINER functions and triggers.


Source tables​

These tables feed the rollup computation. Changes to them mark matching circulator_earnings rows as stale.

signatures​

Signature records grouped into earnings batches.

ColumnTypeRole in dashboard
circulatoruuidBatch key → circulator_earnings.circulator
campaigntextBatch key → circulator_earnings.campaign
teamtextBatch key → circulator_earnings.team
"turnInDate"dateBatch key → circulator_earnings.turn_in_date
statustext'valid' vs non-'invalid' drives counts

Counting logic (count_earnings_batch_signatures):

total_signatures = COUNT(*) FILTER (WHERE status IS DISTINCT FROM 'invalid')
valid_signatures = COUNT(*) FILTER (WHERE status = 'valid')

Index: idx_signatures_circulator_campaign_team_turn_in_date on (circulator, campaign, team, "turnInDate")

rates​

Pay rate and validity incentive threshold per team + campaign.

ColumnTypeRole in dashboard
teamtextLookup key
campaigntextLookup key
ratedouble precision$ per signature → rate_applied
incentive_thresholddouble precisionMinimum validity % to earn on all submitted sigs
"startDate" / "endDate"dateDate-range rate selection
currentbooleanFallback when no date-range match

Lookup order (lookup_earnings_batch_rate):

  1. Rate whose date range contains turn_in_date (most recent startDate wins)
  2. Else the row where current = true for that team + campaign
  3. Defaults to 0 if none found

transactions​

Payment records. Important: transactions.circulator references users.id, not circulators.id. The pipeline resolves circulators.user_id → transactions.circulator.

ColumnTypeRole in dashboard
circulatoruuidFK → users.id (via circulator profile)
campaigntextBatch key
teamtextBatch key
"turnInDate"dateBatch key
"amountPaid"double precisionGenerated column → summed into amount_paid
kittydouble precisionGenerated column → summed into kitty

circulators​

Canonical circulator person records.

ColumnTypeRole in dashboard
iduuidPrimary key used in circulator_earnings and signatures
user_iduuidOptional FK → users.id; links to transactions
firstName, lastNametextDisplay name in dashboard header

campaigns​

ColumnTypeRole in dashboard
idtextCampaign identifier (text, not UUID)
"campaignName"textDisplay label in table rows
statustextActive/inactive filter tabs ('active' vs other)

teams​

ColumnTypeRole in dashboard
idtextTeam slug (e.g. super-admin) — route param primaryTeam
nametextDisplayed in dashboard header

campaign_team_circulators (admin circulator picker)​

When the admin dashboard is wired to live data, the circulator dropdown will likely query this junction table:

ColumnTypeDescription
campaigntextFK → campaigns.id
teamtextFK → teams.id
circulatoruuidFK → circulators.id
typecirculator_type'volunteer' | 'paid'

PK: (campaign, team, circulator)


Earnings formula​

From calc_earnings_amount():

validity_rate = (valid_signatures / total_signatures) × 100   -- 0 if total = 0
incentive_met = validity_rate >= incentive_threshold

amount_earned = IF incentive_met
THEN total_signatures × rate_applied
ELSE valid_signatures × rate_applied

balance = amount_earned - amount_paid - kitty

Bad signatures (shown in UI) are computed client-side:

bad_signatures = total_signatures - valid_signatures

Computation pipeline​

Stale marking (lightweight triggers)​

TriggerTableEffect
trigger_mark_earnings_stale_on_signature_changesignaturesMarks affected batch stale on INSERT/UPDATE/DELETE
trigger_mark_earnings_stale_on_rate_changeratesMarks all batches for that team + campaign stale
trigger_mark_earnings_stale_on_transaction_changetransactionsResolves user → circulator, marks batch stale

Recompute functions​

FunctionPurpose
recompute_circulator_earnings(circulator, campaign, team, turn_in_date)Full recalc for one batch
recompute_stale_earnings(limit)Worker: discovers new batches from signatures, processes stale rows
refresh_my_earnings()On-demand RPC for logged-in circulator (dashboard Refresh button)

Scheduled job​

-- Daily at midnight UTC, processes up to 500 stale batches
SELECT cron.schedule(
'recompute-stale-earnings',
'0 0 * * *',
$$SELECT public.recompute_stale_earnings(500)$$
);