Status: Production (Railway) Domain: picks4all.com | api.picks4all.com Document reflects: Codebase as of 2026-05-03
- System Overview
- Technology Stack
- Project Structure
- Backend Architecture
- Frontend Architecture
- Database Architecture
- Authentication & Security
- API Design Patterns
- Data Flow
- Deployment Architecture
- Branding System
- Configuration
┌──────────────────────────────────────────────────────────────────────┐
│ CLIENTS │
│ Browser (Desktop / Mobile) │
└──────────────────────────┬───────────────────────────────────────────┘
│
│ HTTPS
│
┌──────────────────────────▼───────────────────────────────────────────┐
│ CLOUDFLARE DNS │
│ picks4all.com → CNAME → frontend-next-*.up.railway.app │
│ api.picks4all.com → CNAME → backend-*.up.railway.app │
│ Email Routing: 16 addresses + catch-all │
└─────────┬───────────────────────────┬────────────────────────────────┘
│ │
┌─────────▼──────────────┐ ┌────────▼───────────────────────────────┐
│ FRONTEND SERVICE │ │ BACKEND SERVICE │
│ Railway │ │ Railway │
│ │ │ │
│ Next.js 16 (App Router)│ │ Node.js 22+ / Express 5 / TypeScript │
│ TypeScript + React 19 │ │ Prisma 6.19+ ORM │
│ next-intl v4 │ │ │
│ standalone output │ │ ┌────────┐ ┌──────────┐ ┌────────┐ │
│ │ │ │ Routes │ │ Services │ │ Jobs │ │
│ SSR: public/SEO pages │ │ │ 30 │ │ 29 │ │ 13 │ │
│ CSR: authenticated app │ │ └───┬────┘ └────┬─────┘ └───┬────┘ │
│ │ │ │ │ │ │
│ CSS custom properties │ │ ┌───▼───────────▼───────────▼──────┐ │
│ (no Tailwind) │ │ │ Libraries (34 files) │ │
│ │ │ │ jwt, email, scoring, pricing, │ │
│ │ │ │ ga4, metaCapi, audit, etc. │ │
│ │ │ └──────────────┬───────────────────┘ │
│ │ │ │ │
│ │ │ ┌──────────────▼───────────────────┐ │
│ │ │ │ Prisma ORM Client │ │
│ │ │ └──────────────┬───────────────────┘ │
└─────────────────────────┘ └─────────────────┼───────────────────────┘
│
│ SQL (parameterized)
│
┌─────────────────▼───────────────────────┐
│ POSTGRESQL 16 │
│ Railway managed │
│ │
│ 35 models, 57+ migrations │
│ ACID transactions, indexes, FK, JSON │
└──────────────────────────────────────────┘
External Integrations:
┌────────────────────────┐ ┌─────────────────┐ ┌─────────────────────┐
│ picks4all-scores (1°) │ │ Resend │ │ Google Identity Svcs │
│ API-Football (fallback)│ │ (email out) │ │ Sign-In + verify │
│ Live scores + fixtures │ │ Cloudflare Mail │ │ │
│ │ │ (email in) │ │ │
└────────────────────────┘ └─────────────────┘ └─────────────────────┘
┌────────────────────────┐ ┌─────────────────┐ ┌─────────────────────┐
│ Mercado Pago (CO/COP) │ │ Polar.sh │ │ GA4 + Meta CAPI │
│ Payment Brick + IPN │ │ Hosted USD │ │ (server-side w/ │
│ │ │ checkout │ │ retry queue) │
└────────────────────────┘ └─────────────────┘ └─────────────────────┘
Monorepo + Monolithic Services
- Monorepo: Single Git repository with
/backendand/frontend-next - Backend: Monolithic Express app with service layer, libraries, and cron jobs
- Frontend: Next.js App Router. SSR for public pages, CSR for authenticated app
- Database: Single PostgreSQL instance (Railway managed)
- Communication: REST/JSON over HTTPS. Frontend calls backend via
Authorization: Bearer <JWT>header - State management: Client-side only (localStorage + custom events). No server-side sessions.
| Layer | Technology | Version | Purpose |
|---|---|---|---|
| Runtime | Node.js | 22+ | JavaScript execution |
| Framework | Express | 5.2.1 | HTTP server and routing |
| Language | TypeScript | 5.9.3 | Type-safe JavaScript |
| ORM | Prisma | 6.19.1 | Database client, migrations, type generation |
| Database | PostgreSQL | 16 | Relational data store |
| Validation | Zod | 4.2.1 | Runtime schema validation on all endpoints |
| Auth | jsonwebtoken | 9.0.3 | JWT signing and verification |
| Password | bcrypt | 6.0.0 | Password hashing (salt rounds = 12) |
| OAuth | google-auth-library | 10.5.0 | Google OAuth token verification |
| Resend | 6.6.0 | Transactional email delivery | |
| HTTP Security | helmet | 8.1.0 | Security headers |
| CORS | cors | 2.8.5 | Cross-origin resource sharing |
| Rate Limiting | express-rate-limit | 8.2.1 | Brute-force and abuse protection |
| Cron | node-cron | 4.2.1 | Scheduled jobs (SmartSync, phase sync, reminders) |
| Config | dotenv | 17.2.3 | Environment variable management |
| Testing | Vitest | 4.x | Unit and integration tests |
| Layer | Technology | Version | Purpose |
|---|---|---|---|
| Framework | Next.js | 16.1.6 | Full-stack React framework (App Router, standalone) |
| Language | TypeScript | 5.x | Type-safe JavaScript |
| UI | React | 19.2.3 | Component library |
| i18n | next-intl | 4.8.3 | Internationalization (ES/EN/PT) |
| Drag & Drop | @dnd-kit | 6.x / 10.x | Sortable UI for group standings predictions |
| HTTP | Fetch API | Native | API requests (no Axios) |
| Styling | CSS custom properties | -- | Light theme, no framework (no Tailwind, no CSS-in-JS) |
| Linting | ESLint + eslint-config-next | 9.x | Code quality |
State management: Local component state (useState/useEffect). Auth state via localStorage + custom event system (quiniela:auth). No global state library.
| Component | Technology | Purpose |
|---|---|---|
| Backend hosting | Railway | Node.js service |
| Frontend hosting | Railway | Next.js standalone server |
| Database | Railway PostgreSQL | Managed PostgreSQL 16 |
| DNS | Cloudflare | DNS management, CNAME to Railway, Email Routing |
| Email (outbound) | Resend | Transactional emails (verified domain) |
| Email (inbound) | Cloudflare Email Routing | 16 addresses + catch-all |
| Sports data (primary) | picks4all-scores | In-house scraper, 15s polling during live windows |
| Sports data (fallback) | API-Football (api-sports.io) | ~30 min after estimated FT, fills gaps the scraper missed |
| Payments (CO) | Mercado Pago | Payment Brick + IPN webhooks (COP) |
| Payments (Intl) | Polar.sh | Hosted checkout (USD) |
| Analytics (browser) | Google Analytics (GA4) + GTM | Pageview / event tracking |
| Analytics (server) | GA4 Measurement Protocol + Meta CAPI | Purchase dedup, EMQ uplift, DLQ retry |
| OAuth | Google Identity Services | Google Sign-In |
quiniela-platform/
├── backend/ # Node.js + Express backend
│ ├── prisma/ # Schema + 57+ migrations
│ ├── src/ # TypeScript source
│ ├── dist/ # Compiled JS (gitignored)
│ ├── .env # Environment variables (gitignored)
│ ├── docker-compose.yml # Local PostgreSQL container
│ ├── package.json
│ └── tsconfig.json
├── frontend-next/ # Next.js 16 App Router
│ ├── src/ # TypeScript source
│ ├── public/ # Static assets
│ ├── .env.local # Environment variables (gitignored)
│ ├── next.config.ts # Next.js + next-intl configuration
│ ├── package.json
│ └── tsconfig.json
├── infra/ # Docker compose for local DB
├── docs/ # Documentation
│ ├── PRD.md, ARCHITECTURE.md, DATA_MODEL.md, API_SPEC.md,
│ ├── BUSINESS_RULES.md, GLOSSARY.md, DECISION_LOG.md
│ └── guides/ # Setup, deployment, email, scores, OAuth, analytics
├── CLAUDE.md # Development standards (operational manual)
├── CHANGELOG.md # Version history
├── README.md # Repository entry point
├── TECH_DEBT.md # Tracked tech-debt deferred to post-launch
├── railway.toml # Railway deployment config (backend)
└── .gitignore
backend/src/
├── server.ts # Express entry point + cron startup
├── db.ts # Prisma client singleton
├── middleware/
│ ├── requireAuth.ts # JWT authentication
│ ├── requireAdmin.ts # Platform admin role check
│ └── rateLimit.ts # Rate limiters (api, auth, password, create)
├── lib/ # Utility modules (see repo-map for the full list). Highlights:
│ ├── jwt.ts, password.ts, passwordRules.ts, authCookies.ts
│ ├── googleAuth.ts # Google OAuth token verification
│ ├── audit.ts, roles.ts # Audit logging + role helpers
│ ├── email.ts, emailTemplates.ts, htmlSafe.ts
│ ├── activationUrl.ts # Locale-correct corporate activation links (ADR-063)
│ ├── unsubscribe.ts # Tokenised email unsubscribe links
│ ├── brand.ts, constants.ts, schemas.ts, env.ts
│ ├── scoringPresets.ts # Legacy presets (CLASSIC, OUTCOME_ONLY, EXACT_HEAVY)
│ ├── scoringAdvanced.ts, scoringBreakdown.ts, pickPresets.ts
│ ├── poolCapacity.ts, poolHelpers.ts
│ ├── pricing.ts # USD + COP dynamic pricing with volume discounts
│ ├── amountInWords.ts, saleTerms.ts, issuerInfo.ts # Sales-document helpers (Quote / CC)
│ ├── paymentEvents.ts, paymentEventSources.ts # PaymentEvent source taxonomy
│ ├── ga4.ts, metaCapi.ts # Server-side analytics sinks (DLQ-aware)
│ ├── utm.ts, syntheticFixtureId.ts
│ ├── fixture.ts, serializers.ts, timezone.ts, username.ts
│ └── apiResponse.ts, asyncHelpers.ts, logger.ts, validateBase64Image.ts
├── routes/ # 30 mounted route files. Categories:
│ ├── auth.ts # Register, login, Google OAuth, password reset,
│ │ # email verify, corporate activation
│ ├── me.ts, userProfile.ts # /me/pools, /me/email-preferences, /users/me/profile
│ ├── pools.ts, poolOverview.ts, poolMembers.ts, poolInvites.ts, poolAdmin.ts
│ ├── picks.ts, structuralPicks.ts, structuralResults.ts, groupStandings.ts
│ ├── results.ts # Result publish + leaderboard + breakdown
│ ├── catalog.ts, pickPresets.ts, legal.ts
│ ├── feedback.ts, unsubscribe.ts # Public surfaces
│ ├── corporate.ts # Corporate inquiry, pool create, employees
│ ├── payments.ts # Polar + MP checkout init, country detection
│ ├── salesRedemption.ts # Customer CC redemption (mounted /sales/account-receivables)
│ ├── resendWebhook.ts # Resend bounce/complaint suppression
│ ├── analyticsHealth.ts # Server-analytics health probe
│ ├── admin.ts, adminAnalyticsDashboard.ts, adminCorporate.ts,
│ ├── adminTemplates.ts, adminInstances.ts, adminSettings.ts,
│ ├── adminSales.ts # Admin Quote + Cuenta-de-Cobro issuance (mounted /admin/sales)
├── services/
│ ├── scoresService/ # picks4all-scores client (PRIMARY live scores)
│ ├── smartSync/ # API-Football per-match polling (FALLBACK)
│ ├── resultSync/ # Legacy batch sync (kept for backfill)
│ ├── apiFootball/ # API-Football HTTP client + types
│ ├── mercadopago/ # MP SDK wrapper, signature verification, IPN
│ ├── polar/ # Polar SDK wrapper, webhook verification
│ ├── sales/ # quoteService, accountReceivableService, documentCounterService (ADR-061)
│ ├── authService.ts # Authentication business logic
│ ├── pickService.ts # Pick submission with deadline + lockedPhases enforcement
│ ├── resultService.ts # Result publication logic
│ ├── advancementTrigger.ts # Triggers phase advancement after results
│ ├── poolStateMachine.ts # DRAFT/ACTIVE/COMPLETED/ARCHIVED transitions
│ ├── poolOverviewService.ts # Single-call overview assembly
│ ├── poolMemberService.ts, poolAdminService.ts
│ ├── corporateService.ts, corporateBrandingService.ts
│ ├── paymentService.ts # Cross-gateway payment orchestration
│ ├── adminService.ts, adminInstanceService.ts
│ ├── instanceAdvancement.ts, tournamentAdvancement.ts
│ ├── structuralScoring.ts, structuralAutoPublish.ts, groupStandingsService.ts
│ ├── newMemberDigestService.ts # Daily host digest of new joiners
│ └── deadlineReminderService.ts # Email reminder scheduling logic
├── pdf/ # Sales-document PDF renderers (@react-pdf)
│ ├── QuoteDocument.tsx, renderQuotePdf.tsx
│ └── CcDocument.tsx, renderCcPdf.tsx
├── jobs/ # 13 cron jobs started in server.ts (see §4.5)
│ ├── liveScoresJob.ts # picks4all-scores polling (15s during live windows)
│ ├── smartSyncJob.ts # API-Football fallback (~30min after est. FT)
│ ├── resultSyncJob.ts # Legacy batch sync — dead code, admin-triggered only (not a boot cron)
│ ├── phaseSyncJob.ts # Process PendingPhaseSync queue
│ ├── deadlineReminderJob.ts # Send deadline reminder emails (48h window)
│ ├── fixtureTrackingJob.ts # Track fixture changes from API-Football
│ ├── fixtureVerificationJob.ts # Verify mappings stay aligned
│ ├── newMemberDigestJob.ts # Daily digest of new pool joiners
│ ├── capiRetryJob.ts # Drain FailedAnalyticsEvent DLQ (advisory lock)
│ ├── trackStatusCheckerJob.ts # External status monitoring
│ ├── paymentReconcileJob.ts # Polar stale-payment reconciler (advisory lock)
│ ├── mpPaymentReconcileJob.ts # Mercado Pago reconciler (advisory lock)
│ ├── accountReceivableExpiryJob.ts # Sweeps expired Cuenta-de-Cobro documents
│ └── welcomeEmailFallbackJob.ts # 24h welcome-email safety net (ADR-063)
├── validation/
│ └── pickConfig.ts # Zod schemas for pick configuration
├── schemas/
│ └── templateData.ts # Zod schema for tournament template data
├── scripts/ # Seeds + ad-hoc admin scripts
│ ├── seedAdmin.ts, seedTestAccounts.ts
│ ├── seedWc2026Sandbox.ts, seedUcl2025.ts, seedLegalDocuments.ts
│ ├── initSmartSyncStates.ts, fetchUclData.ts, updateUclR16Draw.ts
│ └── migrateExtraTimeConfig.ts
├── types/
│ ├── express.d.ts # Extend Express.Request with auth
│ └── pickConfig.ts # Pick configuration types
└── wc2026Sandbox.ts # WC2026 data builder
frontend-next/src/
├── app/
│ ├── layout.tsx # Root layout (minimal, no html/body)
│ ├── robots.ts # Dynamic robots.txt generation
│ ├── sitemap.ts # Dynamic sitemap.xml generation
│ ├── manifest.ts # PWA manifest
│ ├── opengraph-image.tsx # Dynamic OG image (ImageResponse)
│ ├── apple-icon.tsx # Apple touch icon
│ ├── icon.tsx # Favicon generation
│ ├── pwa-icon-192/route.tsx # PWA icon 192px
│ ├── pwa-icon-512/route.tsx # PWA icon 512px
│ ├── api/region/route.ts # Client-side region detection endpoint
│ └── [locale]/ # Locale segment (all pages nested here)
│ ├── layout.tsx # Locale layout: <html lang>, NextIntlClientProvider
│ ├── page.tsx # Landing page (SSR)
│ ├── not-found.tsx # 404 page
│ ├── error.tsx # Error boundary
│ ├── login/ # Login page
│ ├── forgot-password/ # Forgot password flow
│ ├── reset-password/ # Reset password flow
│ ├── verify-email/ # Email verification
│ ├── activar-cuenta/ # Corporate employee activation
│ ├── faq/ # FAQ page (SSR + JSON-LD)
│ ├── como-funciona/ # "How it works" (SSR)
│ ├── como-se-juega/ # "How to play" (SSR)
│ ├── que-es-una-quiniela/ # "What is a pool" (SSR)
│ ├── empresas/ # Enterprise landing (public; pool wizard is authed — see below)
│ ├── invite/ # Public invite-preview landing
│ ├── precios/ # Pricing page
│ ├── terminos/ # Terms of service
│ ├── privacidad/ # Privacy policy
│ ├── reembolsos/ # Refund policy
│ ├── mundial-2026/ # WC2026 SEO hub: page, grupos, calendario, sedes,
│ │ # predicciones, reglas-quiniela, como-hacer-quiniela
│ ├── polla-futbolera/ # Regional SEO (ES)
│ ├── prode-deportivo/ # Regional SEO (ES)
│ ├── penca-futbol/ # Regional SEO (ES)
│ ├── porra-deportiva/ # Regional SEO (ES)
│ ├── football-pool/ # Regional SEO (EN)
│ └── (authenticated)/ # Route group: AuthGuard wrapper
│ ├── layout.tsx # AuthGuard + NavBar + Footer + PoolNavRootProvider
│ ├── dashboard/page.tsx # User dashboard (my pools)
│ ├── pools/[poolId]/page.tsx # Pool detail page (single-call overview)
│ ├── pools/join/page.tsx # Join pool by code
│ ├── profile/page.tsx # User profile
│ ├── pago/ # Payment pages (checkout, exitoso, cancelado)
│ ├── crear-pool/page.tsx # Standard pool creation wizard
│ ├── empresas/crear/page.tsx # Corporate pool creation wizard
│ └── admin/ # Platform admin pages
│ ├── analytics/page.tsx # Real-time growth dashboard
│ ├── analytics-health/page.tsx # Server-analytics DLQ health
│ ├── feedback/page.tsx # Feedback inbox
│ ├── ventas/cotizaciones/ # Quote issuance (list, nueva, [id])
│ ├── ventas/cuentas-de-cobro/ # Cuenta-de-Cobro issuance (list, nueva, [id])
│ └── settings/email/page.tsx
├── i18n/
│ ├── routing.ts # next-intl routing config
│ ├── request.ts # next-intl server config (message loading)
│ └── navigation.ts # Typed navigation helpers (Link, redirect)
├── messages/ # Translation JSON files
│ ├── es/ # Spanish (15+ namespace files)
│ ├── en/ # English
│ └── pt/ # Portuguese
├── components/
│ ├── AuthGuard.tsx # Client-side auth gate
│ ├── AuthSlidePanel.tsx # Slide-in login/register panel
│ ├── NavBar.tsx # Authenticated app navigation
│ ├── PublicNavbar.tsx # Public pages navigation
│ ├── Footer.tsx # Site footer
│ ├── LandingContent.tsx # Landing page (client component)
│ ├── LanguageSelector.tsx # Language switcher (ES/EN/PT)
│ ├── BrandLogo.tsx # Picks4All logo
│ ├── PoolConfigWizard.tsx # Pool creation/config wizard
│ ├── pool-wizard/ # Wizard components (context, steps, nav)
│ ├── corporate/ # Corporate components (7 step files + creation wizard)
│ ├── CorporatePoolCreation.tsx # Legacy corporate creation (6-step)
│ ├── EnterpriseLandingContent.tsx # Enterprise landing page content
│ ├── ActivationContent.tsx # Corporate employee activation
│ ├── CorporateEmployeeManager.tsx # Employee management UI
│ ├── GroupStandingsCard.tsx # Draggable group standings
│ ├── groupStandings/ # Group standings sub-components
│ ├── KnockoutMatchCard.tsx # Knockout bracket card
│ ├── StructuralPicksManager.tsx # Structural picks UI
│ ├── PlayerSummary.tsx # Player detail view
│ ├── MobileLeaderboard.tsx # Mobile-optimized leaderboard
│ ├── ScoringBreakdownModal.tsx # Scoring detail modal
│ ├── TeamFlag.tsx # Team flag display
│ ├── PickRulesDisplay.tsx # Pick rules explanation
│ ├── PhaseConfigStep.tsx # Phase configuration in wizard
│ ├── CapacitySelector.tsx # Pool capacity selector
│ ├── PasswordStrengthIndicator.tsx # Password strength UI
│ ├── EmailVerificationBanner.tsx # Email verification reminder
│ ├── EmailPreferencesSection.tsx # Email notification settings
│ ├── NotificationBadge.tsx # Notification badge
│ ├── NotificationBanner.tsx # Banner notifications
│ ├── WhatsNewModal.tsx # "What's new" modal
│ ├── BetaFeedbackBar.tsx # Beta feedback strip
│ ├── FeedbackModal.tsx # Bug/suggestion feedback form
│ ├── PublicPageWrapper.tsx # Wrapper for public content pages
│ ├── RegionalArticlePage.tsx # Template for regional SEO pages
│ ├── FAQAccordion.tsx # Expandable FAQ items
│ ├── JsonLd.tsx # JSON-LD structured data helper
│ ├── Breadcrumbs.tsx # Breadcrumb navigation
│ └── RegisterButton.tsx # CTA registration button
├── hooks/
│ ├── useAuth.ts # Auth state (token, isAuthenticated, isLoading)
│ ├── useIsMobile.ts # Responsive breakpoint detection
│ └── usePoolNotifications.ts # Pool notification polling
├── contexts/
│ └── AuthPanelContext.tsx # Auth slide panel state + redirectTo parameter
├── lib/
│ ├── api/ # Modular API client
│ │ ├── client.ts # requestJson, getApiBase, 401 handling
│ │ ├── auth.ts # login, register, google, password flows
│ │ ├── pools.ts # getMePools, listInstances, createPool, joinPool, overview, members
│ │ ├── picks.ts # match + structural pick upserts
│ │ ├── groupStandings.ts # group standings picks/results
│ │ ├── scoring.ts # match/phase/group breakdowns
│ │ ├── user.ts # profile, email prefs, locale preference, country detect
│ │ ├── admin.ts # admin operations + analytics dashboard
│ │ ├── corporate.ts # corporate pool operations
│ │ ├── sales.ts # quote / cuenta-de-cobro + CC redemption
│ │ ├── payments.ts # checkout init (Polar/MP), country detection
│ │ ├── paymentAttemptEvent.ts # MP Brick lifecycle telemetry beacons (ADR-066)
│ │ └── index.ts # Re-exports
│ ├── auth.ts # Token storage + auth event system
│ ├── brand.ts # Brand identity (mirrors backend)
│ ├── siteConfig.ts # SITE_URL, SITE_NAME, EMAIL_DOMAIN
│ ├── theme.ts # Theme derived from brand
│ ├── pricing.ts # COP + USD tier computation (mirrors backend)
│ ├── analytics.ts # GTM dataLayer event helpers
│ ├── validation.ts # Centralized form constraints
│ └── timezone.ts # Timezone detection
├── data/
│ └── teamFlags.ts # Team code -> flag URL mapping
├── types/
│ └── pickConfig.ts # Pick configuration types
├── proxy.ts # Next.js middleware: www redirect + i18n routing
└── globals.css # Global styles (CSS custom properties)
All backend logic follows the pattern: Route -> Service -> Library
Request
-> Middleware (requireAuth, rateLimit)
-> Route handler (input validation via Zod)
-> Service (business logic, transaction orchestration)
-> Library (utilities: email, scoring, audit)
-> Prisma (database operations)
-> Response
Business logic never lives in route handlers. Routes validate input and call services.
server.ts configures:
- Trust proxy (Railway reverse proxy environment)
- CORS
- JSON body parser (1MB limit)
- Global rate limiting
- Stricter rate limiting on auth endpoints
- Router mounting (30 route files)
- Health check endpoint with version/commit info
- Cron job startup (13 jobs — see §4.5)
Rate Limiting:
| Limiter | Scope | Window | Max Requests |
|---|---|---|---|
apiLimiter |
All endpoints (except /health) | 1 min | 100 |
authLimiter |
Login, register | 15 min | 10 |
passwordResetLimiter |
Forgot/reset password | 1 hour | 5 |
createResourceLimiter |
Pool/invite creation | 1 hour | 20 |
All limits are configurable via RATE_LIMIT_*_MAX environment variables.
Authentication middleware (requireAuth):
- Extract JWT from
Authorization: Bearer <token>header - Verify signature and expiry
- Load user from database, check
status === ACTIVE - Attach
{ userId, platformRole }toreq.auth
Admin middleware (requireAdmin):
- Runs after
requireAuth, checksplatformRole === ADMIN
Key services and their responsibilities:
| Service | Purpose |
|---|---|
scoresService/ |
picks4all-scores HTTP client. Primary live scoring source (15s polling during match live windows). |
smartSync/service.ts |
Per-match API-Football polling. Fallback layer; activates ~30 min after estimated FT for matches the scraper hasn't reported. |
resultService.ts |
Result publication (any source), override, errata, leaderboard recalculation. Enforces source hierarchy (HOST_OVERRIDE > API_CONFIRMED > SCRAPER_PROVISIONAL > HOST_PROVISIONAL > HOST_MANUAL). |
advancementTrigger.ts |
Triggers phase advancement after a match result publishes. |
poolStateMachine.ts |
Pool lifecycle transitions (DRAFT -> ACTIVE -> COMPLETED -> ARCHIVED) |
instanceAdvancement.ts |
Tournament phase advancement (group -> R16 -> QF -> SF -> F) |
tournamentAdvancement.ts |
Bracket advancement logic (populating knockout matches) |
pickService.ts |
Pick submission with deadline + lockedPhases enforcement. Reads from pool.fixtureSnapshot ?? instance.dataJson. |
poolOverviewService.ts |
Single-call overview assembly (matches, picks, results, leaderboard) |
corporateService.ts / corporateBrandingService.ts |
Corporate pool creation, employee invitation, branding edits with audit trail. |
paymentService.ts |
Cross-gateway orchestration (Polar USD / Mercado Pago COP), capacity expansion, idempotent webhook handling. All COMPLETED transitions route through markPaymentCompleted (ADR-065). |
sales/quoteService.ts, sales/accountReceivableService.ts, sales/documentCounterService.ts |
Sales-document subsystem (ADR-061): server-derived Quote + Cuenta-de-Cobro issuance, gap-free consecutive numbering, soft-revoke only, atomic CC redemption. |
deadlineReminderService.ts |
Email reminder scheduling (48h window, excludes pools with muted reminders) |
newMemberDigestService.ts |
Daily digest of new joiners delivered to pool hosts. |
structuralScoring.ts |
Scoring for group standings and knockout bracket predictions |
| Job | File | Schedule | Purpose |
|---|---|---|---|
| Live Scores | liveScoresJob.ts |
15s during match live windows | Primary results channel — polls picks4all-scores. Gated by PlatformSettings.scoresServiceEnabled. |
| Smart Sync | smartSyncJob.ts |
Periodic (configurable) | Fallback — polls API-Football and only publishes results the scraper hasn't already reported. |
| Result Sync (legacy) | resultSyncJob.ts |
Not started | Dead code — runSyncJob() is unreachable and the scheduled task is never created. Not wired into server.ts; candidate for removal. |
| Phase Sync | phaseSyncJob.ts |
Periodic | Drains the PendingPhaseSync queue (advances pool fixtureSnapshots after a phase completes). |
| Fixture Tracking | fixtureTrackingJob.ts |
Periodic | Tracks fixture changes from API-Football. |
| Fixture Verification | fixtureVerificationJob.ts |
Periodic | Re-verifies that mappings stay aligned. |
| Deadline Reminders | deadlineReminderJob.ts |
Periodic | Email reminders 48h before kickoff (excludes muted pools). |
| New-Member Digest | newMemberDigestJob.ts |
Daily | Digest of new joiners delivered to hosts. |
| CAPI Retry | capiRetryJob.ts |
Periodic | Drains FailedAnalyticsEvent DLQ for GA4 MP / Meta CAPI; advisory-locked so multi-replica deploys never double-send. |
| Track Status | trackStatusCheckerJob.ts |
Periodic | External status monitoring. |
| Payment Reconcile (Polar) | paymentReconcileJob.ts |
Periodic | Reconciles stale Polar PoolPayment rows against the gateway; advisory-locked. Flags discrepancies for human review (intentional asymmetry vs MP). |
| Payment Reconcile (MP) | mpPaymentReconcileJob.ts |
Periodic | Reconciles stale Mercado Pago payments; advisory-locked. Auto-completes approved payments via markPaymentCompleted. |
| Account-Receivable Expiry | accountReceivableExpiryJob.ts |
Periodic | Sweeps and expires overdue Cuenta-de-Cobro documents; advisory-locked. |
| Welcome-Email Fallback | welcomeEmailFallbackJob.ts |
Periodic | 24h safety net that sends the deferred welcome email when the locale-preference handoff never fired (ADR-063); advisory-locked. |
All request bodies validated with Zod schemas before processing. Key validation files:
validation/pickConfig.ts-- Pick configuration and preset schemasschemas/templateData.ts-- Tournament template data schemalib/schemas.ts-- Shared field schemas (email, username, password, etc.)
| Page Type | Rendering | Examples |
|---|---|---|
| Public/SEO pages | SSR (server-side) | Landing, FAQ, how-it-works, legal, regional SEO, pricing |
| Authenticated app | CSR (client-side) | Dashboard, pool page, profile, admin |
| Corporate public | CSR (client component) | Enterprise landing, activation |
Routing configuration (i18n/routing.ts):
export const routing = defineRouting({
locales: ["es", "en", "pt"],
defaultLocale: "es",
localePrefix: "as-needed", // ES has no prefix; EN/PT have /en/, /pt/
pathnames: {
"/como-funciona": {
es: "/como-funciona",
en: "/how-it-works",
pt: "/como-funciona",
},
"/terminos": {
es: "/terminos",
en: "/terms",
pt: "/termos",
},
// ... more localized paths
},
});URL patterns:
picks4all.com/-- Spanish (default, no prefix)picks4all.com/en/-- Englishpicks4all.com/pt/-- Portuguesepicks4all.com/en/how-it-works-- Localized path
Messages: JSON files split by namespace (auth, dashboard, pool, seo, faq, corporate, etc.). 23 namespaces per locale.
Next.js middleware handles:
- www redirect:
www.picks4all.com->picks4all.com(301) - i18n routing: Locale detection, NEXT_LOCALE cookie persistence, redirect via
next-intl/middleware
1. User logs in or registers
-> Backend returns JWT
2. setToken(jwt) saves to localStorage
-> fires "quiniela:auth" custom event
3. useAuth() hook listens for auth changes
-> exposes { token, isAuthenticated, isLoading }
4. AuthGuard wraps (authenticated) route group
-> redirects to landing if no token
5. API client auto-injects Authorization: Bearer <token>
6. On 401 response:
-> clearToken() fires event
-> useAuth updates
-> AuthGuard redirects to landing
Google Sign-In:
- GIS library loaded via
<Script strategy="lazyOnload"> - On success, frontend sends
idTokentoPOST /auth/google - Backend verifies with
google-auth-library, creates or links user
Modular API client organized by domain:
| Module | Methods |
|---|---|
client.ts |
requestJson(), getApiBase(), 401 handling, session expiry |
auth.ts |
login, register, loginWithGoogle, forgotPassword, resetPassword, verifyEmail |
pools.ts |
getMePools, listInstances, createPool, joinPool, getPoolOverview, createInvite, member management (promote, approve, kick, ban) |
picks.ts |
match + structural pick upserts |
groupStandings.ts |
group standings picks and results |
scoring.ts |
match/phase/group breakdowns |
user.ts |
getUserProfile, updateUserProfile, getUserEmailPreferences, setLocalePreference, detectCountry |
admin.ts |
admin operations, feedback, email settings, analytics dashboard |
corporate.ts |
createCorporatePool, addEmployees, sendInvitations |
sales.ts |
quote / cuenta-de-cobro listing + CC redemption |
payments.ts |
checkout init (Polar/MP), MP Brick process, country detection |
paymentAttemptEvent.ts |
MP Brick lifecycle telemetry beacons (ADR-066) |
- CSS custom properties in
globals.css(no Tailwind, no CSS-in-JS) - Light theme only (
color-scheme: light) - Utility CSS classes (
.card,.badge,.button) - Mobile-first responsive design
experimental.inlineCss: truein Next.js config (eliminates render-blocking CSS)
| Feature | Implementation |
|---|---|
| Metadata | generateMetadata() in every public layout/page |
| OG images | Dynamic generation via opengraph-image.tsx (ImageResponse) |
| Sitemap | sitemap.ts generates XML dynamically |
| Robots | robots.ts generates robots.txt |
| JSON-LD | Structured data on landing, FAQ, organization pages |
| hreflang | All pages include es, en, pt, and x-default alternates |
| Regional pages | Locale-specific content pages targeting regional search terms |
- Production: Railway managed PostgreSQL 16, auto-backups
- Local development: Docker container via
backend/docker-compose.yml - ORM: Prisma 6.19+ with type-safe client and migration system
The full schema reference lives in docs/DATA_MODEL.md; the categories below are a summary.
| Category | Models |
|---|---|
| Users | User |
| Tournaments | TournamentTemplate, TournamentTemplateVersion, TournamentInstance |
| Pools | Pool, PoolMember, PoolInvite |
| Predictions | Prediction, StructuralPrediction, GroupStandingsPrediction |
| Results | PoolMatchResult, PoolMatchResultVersion, PoolMatchOverride, StructuralPhaseResult, GroupStandingsResult |
| Sync | MatchExternalMapping, MatchSyncState, ResultSyncLog, PendingPhaseSync |
| Corporate | Organization, OrganizationInquiry, CorporateInvite, OrganizationBrandingAudit |
| Payments | PoolPayment, PaymentEvent |
| Sales | Quote, AccountReceivable, DocumentCounter |
| Platform / ops | AuditEvent, BetaFeedback, LegalDocument, PlatformSettings, DeadlineReminderLog, EmailSuppression, FailedAnalyticsEvent |
- Normalization: Third Normal Form (3NF)
- Foreign keys: Enforce referential integrity
- Indexes: Primary keys + compound indexes on frequently queried columns
- Immutability: Results and published templates are append-only/versioned
- Soft deletes: Status fields (ACTIVE, BANNED, LEFT, ARCHIVED) instead of hard deletes
- Audit trail:
createdAtUtc,updatedAtUtcon all tables - JSON fields: Flexible data for
pickJson,dataJson,pickTypesConfig - Row-level locking:
SELECT ... FOR UPDATEfor pool capacity enforcement
- 57+ migrations applied since
20251228053519_init_m0. Each new feature lands its own migration; the count is fluid andnpx prisma migrate statusis the source of truth, not this doc. - Created with
npx prisma migrate dev --name <name> - Production migrations run automatically on deploy:
prisma migrate deploy && node dist/server.js
| Property | Value |
|---|---|
| Algorithm | HMAC-SHA256 |
| Expiry | 4 hours |
| Payload | { userId, platformRole, iat, exp } |
| Storage | localStorage (frontend) |
| Refresh | None (re-authenticate after expiry) |
| Revocation | None (stateless) |
- Frontend loads Google Identity Services (
<Script strategy="lazyOnload">) - User clicks "Sign in with Google"
- Google returns
idTokento frontend callback - Frontend sends
idTokentoPOST /auth/google - Backend verifies token with
google-auth-library - If email matches existing user: link accounts. Otherwise: create new user.
- Backend returns JWT
- Generated with
crypto.randomBytes(CRYPTO_BYTES.TOKEN)— 32 bytes / 64 hex chars - Stored in
CorporateInvitemodel (status: PENDING / SENT / ACTIVATED / FAILED) - 30-day expiry
- Single-use: activation atomically claims the invite (
updateMany WHERE status IN (PENDING, SENT, FAILED)); concurrent attempts surface asALREADY_ACTIVATED - Resend rotates the token:
POST /corporate/pools/:poolId/employees/:inviteId/resendinvalidates the previous token and resets the 30-day expiry - Verification:
GET /auth/check-corporate-invite?token=xxx - Activation:
POST /auth/activate-corporate(creates account + joins pool, refuses withSESSION_MISMATCHif the request cookie identifies a user whose email differs frominvite.email)
- bcrypt with salt rounds = 12
- Strength validation: min 8 chars, 1 uppercase, 1 number, 1 special character
Configured in next.config.ts:
- Content-Security-Policy: Restricts script, style, image, connect, frame sources
- Strict-Transport-Security: HSTS with 2-year max-age, includeSubDomains, preload
- X-Frame-Options: DENY
- COOP intentionally omitted (Google Sign-In popup compatibility)
| Layer | Mechanism |
|---|---|
| Request validation | Zod schemas on all endpoint inputs |
| SQL injection | Prisma parameterized queries |
| XSS | React/Next.js automatic JSX escaping |
| Rate limiting | Per-endpoint limits (configurable via env) |
| CORS | Configured origins via SITE_DOMAIN env var |
Full reference lives in docs/API_SPEC.md. Highlights below.
AUTH
POST /auth/register, /auth/login, /auth/google
POST /auth/forgot-password, /auth/reset-password
GET /auth/verify-email, /auth/check-corporate-invite
POST /auth/activate-corporate
USER
GET /me/pools
GET/PUT /me/email-preferences
GET/PATCH /users/me/profile
POOLS
POST /pools Create pool (standard or corporate)
POST /pools/join Join pool by code
GET /pools/:id/overview Single-call pool overview
PUT /pools/:id/picks/:matchId Upsert match pick
PUT /pools/:id/structural-picks/:phaseId Upsert structural pick
PUT /pools/:id/group-standings/:phaseId/:groupId Group standings pick
PUT /pools/:id/results/:matchId Publish/update result
POST /pools/:id/members/:mid/(promote|approve|kick|ban)
CATALOG
GET /catalog/instances Available tournament instances
GET /pick-presets Pick configuration presets
CORPORATE
POST /corporate/inquiry
POST /corporate/pools Create corporate pool
POST /corporate/pools/:id/employees Add employees
POST /corporate/pools/:id/send-invitations Send invitations
POST /corporate/pools/:id/employees/:inviteId/resend
GET /corporate/pools/:id/employees-csv-template
PAYMENTS
POST /payments/checkout Init Polar (USD) checkout
POST /payments/mp-checkout Init MP (COP) checkout (Brick preference)
POST /payments/mp-process Process MP Brick submission
GET /payments/country Cloudflare-based COP/USD routing
GET /payments/pool/:poolId/status Latest PoolPayment status (post-checkout polling)
POST /payments/webhook Polar webhook (raw body, signature verified)
POST /payments/mp-webhook MP IPN (HMAC + timestamp drift)
SALES (ADR-061)
POST /sales/account-receivables/redeem Customer redeems a Cuenta de Cobro (authed) — atomic
CC-claim + PoolPayment in one tx, blocks on pricing drift
ADMIN (gated by requireAdmin)
GET /admin/ping
GET /admin/analytics/dashboard Platform-wide growth/health snapshot
CRUD /admin/templates Tournament template management
CRUD /admin/instances Instance management + advancement
CRUD /admin/corporate Corporate org / inquiry management
GET/PUT /admin/settings/email Email toggles + scoresServiceEnabled
GET /admin/feedback BetaFeedback inbox
POST/GET /admin/sales/quotes Issue / list quotes (+ /:id, /:id/pdf, /:id/status)
POST/GET /admin/sales/account-receivables Issue / list Cuentas de Cobro (+ /:id, /:id/pdf, /:id/status)
WEBHOOKS / OTHER
POST /feedback Submit user feedback (auth optional)
GET /legal/:type Active TOS / privacy doc per locale
POST /webhooks/resend Resend bounce/complaint suppression
GET /unsubscribe/:token Tokenised email unsubscribe
GET /analytics-health Server-analytics health probe
Success:
{ "id": "uuid", "name": "Pool Name", "createdAtUtc": "2026-01-02T10:00:00Z" }Error:
{
"error": "ERROR_CODE",
"message": "Human-readable description",
"details": {}
}Status codes: 200 (OK), 201 (Created), 400 (Bad Request), 401 (Unauthorized), 403 (Forbidden), 404 (Not Found), 409 (Conflict/Business rule violation), 429 (Rate limit exceeded)
GET /pools/:poolId/overview returns everything needed for the pool page in one request:
- Pool details + tournament instance info
- All matches with team info, grouped by phase
- User's picks for all matches
- All published results
- Leaderboard with rankings
- Pick configuration and scoring rules
- Member list and roles
This eliminates 5-6 separate API calls.
User enters score (2-1)
│
▼
PUT /pools/:id/picks/:matchId
│
▼
Route: Zod validation
│
▼
Service: pickService.ts
├── Verify membership (ACTIVE status)
├── Verify match exists in pool's fixture snapshot
├── Check deadline not passed: now < (kickoffUtc - deadlineMinutes)
│ └── If passed: 409 CONFLICT (DEADLINE_PASSED)
├── UPSERT Prediction record (unique per poolId/userId/matchId)
└── Write AuditEvent
│
▼
200 OK { prediction }
Layer 1 — picks4all-scores (primary):
liveScoresJob (every 15s during live windows; gated by
PlatformSettings.scoresServiceEnabled)
│
▼
GET picks4all-scores: live scores for tracked fixtures
│
▼
For each match update:
├── If still in play:
│ └── Publish SCRAPER_PROVISIONAL version
│ (will be overwritten by API_CONFIRMED later)
├── If FT confirmed by scraper AND grace period elapsed
│ (SCORES_GRACE_PERIOD_MS = 5 min):
│ ├── Publish PoolMatchResultVersion (SCRAPER_PROVISIONAL)
│ ├── Recalculate leaderboard
│ ├── Trigger advancementTrigger
│ └── Send "result published" emails
└── Always honour source hierarchy:
HOST_OVERRIDE > API_CONFIRMED > SCRAPER_PROVISIONAL
> HOST_PROVISIONAL > HOST_MANUAL
Layer 2 — API-Football (fallback, ~30 min after estimated FT):
smartSyncJob (cron, configurable)
│
▼
Query MatchSyncState records (kickoff + FIRST_CHECK_MIN to
kickoff + FINISH_CHECK_MIN)
│
▼
For each active match without a current API_CONFIRMED version:
├── Call API-Football: GET /fixtures?id={externalId}
├── If match finished AND scraper hasn't already confirmed:
│ ├── Publish PoolMatchResultVersion (API_CONFIRMED) — UPGRADES
│ │ any previous SCRAPER_PROVISIONAL version
│ ├── Recalculate leaderboard, trigger advancement
│ └── Write ResultSyncLog
└── Else: update MatchSyncState, retry next cycle
Host override (any time): mandatory reason, creates a new
PoolMatchResultVersion with source = HOST_OVERRIDE, which is the
top of the hierarchy and is never overwritten by sync layers.
Admin triggers advancement (or auto-triggered after all phase results)
│
▼
instanceAdvancement.ts
├── Verify all matches in current phase have results
├── Calculate group standings (if GROUP phase)
├── Determine advancing teams based on tournament rules
├── Populate placeholder teams in next phase matches
│ (e.g., "Winner Group A" -> "Mexico")
├── Update instance dataJson with resolved teams
├── Create PendingPhaseSync for each affected pool
└── Write AuditEvent (PHASE_ADVANCED)
│
▼
phaseSyncJob.ts (processes PendingPhaseSync records)
├── Update each pool's fixtureSnapshot with new teams
└── Mark PendingPhaseSync as processed
User fills form (name, instance, preset, config)
│
▼
POST /pools
│
▼
Route: Zod validation
│
▼
Service:
BEGIN TRANSACTION
├── Verify instance exists and is ACTIVE
├── Create Pool (state: DRAFT)
├── Create PoolMember (role: HOST)
├── Create PoolInvite (auto-generated code)
└── Create fixtureSnapshot from instance data
COMMIT
│
▼
Write AuditEvent (POOL_CREATED)
│
▼
201 Created { pool, membership, inviteCode }
Host opens capacity upgrade
│
▼
Frontend → GET /payments/country
(Cloudflare CF-IPCountry header → CO ⇒ MP, else ⇒ Polar)
│
▼
POST /payments/checkout (Polar, USD)
or POST /payments/mp-checkout (MP Brick preference)
+ POST /payments/mp-process (MP Brick submission)
│
▼
Backend: paymentService creates PoolPayment row
├── Captures fbp / fbc / ip / ua for Meta CAPI Advanced Matching
├── Idempotency: re-entries return the existing checkoutId / preferenceId
└── Returns hosted-checkout URL (Polar) or Brick preference (MP)
│
▼
Customer pays
│
▼
Webhook arrives (POST /payments/webhook — Polar, raw body
│ POST /payments/mp-webhook — MP IPN)
├── Verify signature (return 401 on mismatch)
├── MP-only: reject events whose timestamp drifts > MP_WEBHOOK_MAX_DRIFT_MS
├── markPaymentCompleted (single entry point — ADR-065) BEGIN TRANSACTION
│ ├── INSERT PaymentEvent { source, polarEventId } — partial unique index
│ │ `PaymentEvent_polarEventId_unique_when_set` is enforced only when
│ │ polarEventId is non-NULL and is shared across gateways (Polar event
│ │ id or MP composite `mp-{id}-{status}`). `source` discriminates
│ │ POLAR_WEBHOOK / MP_WEBHOOK / CLIENT / RECONCILER / SERVER.
│ ├── UPDATE PoolPayment status = COMPLETED, paidAtUtc = now()
│ ├── UPDATE Pool maxParticipants = toCapacity, reset capacity
│ │ notification flags so warning emails can fire again if it refills
│ └── Settle linked AccountReceivable + write AuditEvent (if CC-backed)
│ COMMIT (so rollback also frees the idempotency slot for a real retry)
├── Post-tx fan-out: admin notification, GA4 MP + Meta CAPI Purchase
│ (deduped by metaEventId), receipt email
│ On retry-budget exhaust → enqueue FailedAnalyticsEvent (see §9.6)
└── Return 200. Processing errors return 5xx so the gateway retries.
The Sales subsystem lets an admin pre-issue a Quote and convert it into a
Cuenta de Cobro (CC) that the customer later redeems to upgrade pool
capacity without going through a live gateway charge. Documents are
soft-revoke only (cancellation sets status = CANCELLED; the consecutive
number is preserved so the series shows no gaps) and pricing is always
server-derived via lib/pricing.ts — the admin cannot override the amount.
Admin issues Quote → converts to AccountReceivable (PENDING)
├── DocumentCounter assigns the next gap-free consecutive number
└── renderQuotePdf / renderCcPdf produce the downloadable PDF
│
▼
Customer redeems: POST /sales/account-receivables/redeem
├── BEGIN TRANSACTION
│ ├── Atomic claim: tx.accountReceivable.updateMany WHERE status = PENDING
│ ├── Drift guard: CC snapshot vs live pricing.ts — mismatch →
│ │ 409 CONFLICT (cc_pricing_drift) + admin alert
│ ├── PoolPayment.create → markPaymentCompleted (shared completion path)
│ └── AuditEvent
│ COMMIT
│
▼
accountReceivableExpiryJob sweeps overdue PENDING CCs (advisory-locked)
sendCapiEvent / sendGa4Event
│
▼
Try in-process retry ladder
├── Success → done
└── Exhausted → INSERT FailedAnalyticsEvent
{ provider, eventName, eventId, payloadJson,
attemptCount, lastError, nextRetryAt }
│
▼
capiRetryJob (cron)
├── Acquire Postgres advisory lock (multi-replica safety)
├── findMany WHERE resolvedAt IS NULL
│ AND nextRetryAt <= now()
│ ORDER BY nextRetryAt
├── Replay payloadJson verbatim against the sink
│ ├── 2xx → resolvedAt = now()
│ ├── 4xx permanent → resolvedAt = now() (no retry)
│ └── Else → bump attemptCount, schedule next retry
└── Release lock
The full retry ladder, dedup keys, and purge policy live in
docs/guides/ANALYTICS_PIPELINE.md.
┌──────────────────────────────────────────────────────────────────┐
│ Cloudflare DNS │
│ picks4all.com → CNAME → frontend-next-*.up.railway.app │
│ api.picks4all.com → CNAME → backend-*.up.railway.app │
│ Email Routing: 16 addresses + catch-all → team inbox │
└──────────┬──────────────────────────┬────────────────────────────┘
│ │
┌──────────▼─────────────┐ ┌────────▼──────────────────┐
│ Railway Service: │ │ Railway Service: │
│ frontend-next │ │ backend │
│ │ │ │
│ Build: │ │ Build: │
│ npm install │ │ npm install │
│ next build │ │ prisma generate && tsc │
│ │ │ │
│ Start: │ │ Start: │
│ node .next/ │ │ prisma migrate deploy │
│ standalone/ │ │ && node dist/server.js │
│ server.js │ │ │
│ │ │ Cron jobs start on boot │
└─────────────────────────┘ └────────┬─────────────────┘
│
┌────────▼─────────────────┐
│ Railway PostgreSQL │
│ PostgreSQL 16 │
│ Managed, auto-backups │
└───────────────────────────┘
Git-based deploys: Both services auto-deploy on push to main.
Backend deployment (railway.toml):
[build]
builder = "nixpacks"
buildCommand = "cd backend && npm install && npm run build"
[deploy]
startCommand = "cd backend && npm run start"Frontend deployment: Separate Railway service:
- Build:
cd frontend-next && npm install && npm run build - Start:
cd frontend-next && node .next/standalone/server.js output: "standalone"innext.config.ts
Prerequisites: Node.js 22+, Docker Desktop, npm
Backend:
cd backend
docker compose up -d # Start local PostgreSQL
npm install
npx prisma migrate dev # Run migrations
npm run seed:test-accounts # Seed test data
npm run dev # ts-node-dev on port 3000Frontend:
cd frontend-next
npm install
npm run dev # Next.js dev serverBrand identity is defined in two mirrored files that must be kept in sync:
| File | Purpose |
|---|---|
backend/src/lib/brand.ts |
Email templates, notifications |
frontend-next/src/lib/brand.ts |
Theme, images, OG generation |
Both export a BRAND object with: name, domain, primary, primaryLight, primaryDark, secondary, accent, gradient, gradientAlt, text, textMuted, background, card.
The backend supports runtime brand overrides via the BRAND_COLORS_JSON environment variable:
{"primary": "#ff0000", "gradient": "linear-gradient(...)"}This merges with defaults without requiring a redeploy.
| File | Purpose |
|---|---|
frontend-next/src/lib/siteConfig.ts |
SITE_URL, SITE_NAME, EMAIL_DOMAIN |
frontend-next/src/lib/theme.ts |
CSS theme values derived from brand |
| Category | Constants |
|---|---|
| Time | MS.SECOND, MS.MINUTE, MS.HOUR, MS.DAY |
| Token expiry | Email verification (24h), password reset (1h), corporate invite (30d), pool invite (30d) |
| Crypto sizes | Token (32 bytes), pool invite code (6 bytes), username suffix (3 bytes) |
| Match sync | First check (5 min after kickoff), finish check (110 min after kickoff) -- configurable via env |
| Locales | ["es", "en", "pt"], default: "es" |
| User rules | Username change cooldown (30 days), min age (13), max age (120) |
| Pagination | Default limit (50), max limit (100) |
| Reserved usernames | admin, root, system, quiniela, api, www |
| Placeholder teams | Prefixes that block pick submission: t_TBD, W_, RU_, L_, 3rd_ |
All configurable values are loaded from environment variables. Key categories:
| Category | Variables |
|---|---|
| Domain | SITE_DOMAIN, FRONTEND_URL |
| Auth | JWT_SECRET |
RESEND_API_KEY, RESEND_FROM_EMAIL |
|
| Sports data | API_FOOTBALL_KEY |
| Rate limiting | RATE_LIMIT_API_MAX, RATE_LIMIT_AUTH_MAX, etc. |
| Match sync | MATCH_SYNC_FIRST_CHECK_MIN, MATCH_SYNC_FINISH_CHECK_MIN |
| Frontend public | NEXT_PUBLIC_API_URL, NEXT_PUBLIC_SITE_DOMAIN |
| Branding | BRAND_COLORS_JSON (runtime override) |
All configuration comes from:
- Environment variables (secrets, URLs, feature flags)
- Centralized constants (
lib/constants.ts,lib/brand.ts,lib/siteConfig.ts) - Database (platform settings, legal documents, tournament data)
Static mappings (like team flag URLs in data/teamFlags.ts) exist only as fallbacks.