Three new tables — board (league) and BoardTeam (a team in that league, which carries the per-board stat and points to a shared store/campaign) — plus a stat-event log, a session field, and a leads table. There is no separate "entry" table: a BoardTeam is the per-board entry.
Schema lives in packages/db/src/schema/merchLeaderboardTables.ts (registered via packages/db/src/schema/index.ts). Access functions live in packages/db/src/access/merch/merch-leaderboard.ts and are exported from packages/db/src/index.ts.
merch_leaderboard (a board = a league)merch_leaderboard
id text pk (nanoid)
slug varchar(255) unique not null (the URL, e.g. 'world-cup', 'nba', 'nba-finals')
name varchar(255) not null (display name, e.g. 'World Cup')
isDefault boolean default false (exactly one true -> root '/' redirects here)
-- chrome (per board)
title, subtitle text
heroImageUrls json $type<string[]> default []
backgroundColor varchar(7)
sponsorLogoUrl, sponsorLinkUrl text (nullable; none yet)
footerText, footerLink text
storeLinkTarget varchar default 'same' ('same' | 'new' tab)
-- ops
isActive boolean default true (kill switch for this board)
userCap integer default 30000
createdAt / updatedAt timestamptz
indexes: unique(slug), partial unique(isDefault) where isDefault
merch_board_team (a team in a league + per-board stat)merch_board_team
id text pk (nanoid)
slug varchar(255) unique not null (e.g. 'spain-football' = <state>-<league>)
leaderboardId text -> merch_leaderboard.id not null (the board this team belongs to)
name varchar(255) not null (display name, e.g. 'Spain')
campaignId text -> merch_campaign.id (nullable)
-- the SHARED store/experience; many BoardTeams may reuse one.
-- SINGLE SOURCE OF TRUTH for the store target; the handoff URL is
-- built at runtime from the campaign's accountSlug + slug.
flagImgUrl text (flag / logo image)
countryCode varchar(8) (nullable; e.g. 'es', 'gb-eng' for flag-CDN fallback)
mainColor varchar(7) (primary color — used for the stat bar)
colors json $type<string[]> default [] (full ordered swatch)
facePaintImageUrls json $type<string[]> default [] (example face-paint photos)
searchTerms text (nullable; synonyms, e.g. "USA, United States")
seedCount integer default 0 (per-board popularity baseline so the board isn't all-zero)
isActive boolean default true
createdAt / updatedAt timestamptz
indexes: unique(slug), (leaderboardId), (campaignId), trigram/tsvector(name, searchTerms)
Key rules:
leaderboardId). Stats are per BoardTeam, so spain-football and spain-basketball have independent counters.campaignId. The credit always lands on the BoardTeam, never the store.seedCount + COUNT(stat events for the BoardTeam).pct = count / SUM(count over the board's active BoardTeams) * 100.count first within the board (no stored rank).externalUrl column — it was dropped (migration 0127) so the campaign pointer is the single source of truth.merch_leaderboard_stat_event (source of truth for live counts)merch_leaderboard_stat_event
id, boardTeamId, merchSessionId, userId (nullable),
eventType varchar default 'face_paint_completed', ipAddress, createdAt
indexes: (boardTeamId) -- live count = COUNT(*) GROUP BY boardTeamId
unique: (merchSessionId, eventType) -- idempotency: one completion per session
Counting events is the single source of truth (no counter to keep in sync). At this scale a GROUP BY boardTeamId is trivial; a cached counter can be added later if needed.
merch_session (which BoardTeam a generation credits)merch_session
+ boardTeamId text (nullable) (the BoardTeam credited for this generation/vote)
Set at handoff (the BoardTeam the user picked, which already implies its board). On a successful face-paint generation, the backend writes one stat event for that BoardTeam — a per-board credit.
lead_submission (For Teams / For Brands)lead_submission
id, kind ('team'|'brand'), orgName, contactPerson, email, phone, message,
source ('face.zooly.ai'), createdAt
Access in packages/db/src/access/leadSubmission.ts (listLeadSubmissions powers the admin leads view).
boardTeam.seedCount + COUNT(stat events for that BoardTeam). No maintained counter to drift.count / SUM(count over the board's active BoardTeams) * 100. Nothing is stored as a "stat value".seedCount to a popularity baseline so the board isn't all-zero at launch; real completions add on top.boardTeamId (a direct store visit) is not credited.Hand-written and applied locally via db:migrate against .env.local:
packages/db/drizzle/0126_merch_leaderboard_tables.sqlpackages/db/drizzle/0127_drop_board_team_external_url.sqlpackages/db/drizzle/meta/_journal.jsonFollow the
_journal.jsontimestamp rule (maxcreated_atindrizzle.__drizzle_migrations+ 1000). Neverdb push; never run on a remote DB without explicit approval.
On This Page
[object Object], (a board = a league)[object Object], (a team in a league + per-board stat)[object Object], (source of truth for live counts)[object Object], (which BoardTeam a generation credits)[object Object], (For Teams / For Brands)Stat semantics (seeding → real participation)Migrations