Skip to content

Latest commit

 

History

History
1247 lines (1071 loc) · 62.4 KB

File metadata and controls

1247 lines (1071 loc) · 62.4 KB

Technical Architecture

Picks4All

Status: Production (Railway) Domain: picks4all.com | api.picks4all.com Document reflects: Codebase as of 2026-05-03


Table of Contents

  1. System Overview
  2. Technology Stack
  3. Project Structure
  4. Backend Architecture
  5. Frontend Architecture
  6. Database Architecture
  7. Authentication & Security
  8. API Design Patterns
  9. Data Flow
  10. Deployment Architecture
  11. Branding System
  12. Configuration

1. System Overview

1.1 High-Level Architecture

┌──────────────────────────────────────────────────────────────────────┐
│                           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)       │
  └────────────────────────┘  └─────────────────┘  └─────────────────────┘

1.2 Architecture Style

Monorepo + Monolithic Services

  • Monorepo: Single Git repository with /backend and /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.

2. Technology Stack

2.1 Backend

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
Email 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

2.2 Frontend

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.

2.3 Infrastructure

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

3. Project Structure

3.1 Monorepo Layout

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

3.2 Backend Directory Structure

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

3.3 Frontend Directory Structure

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)

4. Backend Architecture

4.1 Request Processing Pattern

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.

4.2 Express Application Entry Point

server.ts configures:

  1. Trust proxy (Railway reverse proxy environment)
  2. CORS
  3. JSON body parser (1MB limit)
  4. Global rate limiting
  5. Stricter rate limiting on auth endpoints
  6. Router mounting (30 route files)
  7. Health check endpoint with version/commit info
  8. Cron job startup (13 jobs — see §4.5)

4.3 Middleware

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

  1. Extract JWT from Authorization: Bearer <token> header
  2. Verify signature and expiry
  3. Load user from database, check status === ACTIVE
  4. Attach { userId, platformRole } to req.auth

Admin middleware (requireAdmin):

  • Runs after requireAuth, checks platformRole === ADMIN

4.4 Service Layer

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

4.5 Cron Jobs

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.

4.6 Validation

All request bodies validated with Zod schemas before processing. Key validation files:

  • validation/pickConfig.ts -- Pick configuration and preset schemas
  • schemas/templateData.ts -- Tournament template data schema
  • lib/schemas.ts -- Shared field schemas (email, username, password, etc.)

5. Frontend Architecture

5.1 Rendering Strategy

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

5.2 Internationalization (next-intl v4)

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/ -- English
  • picks4all.com/pt/ -- Portuguese
  • picks4all.com/en/how-it-works -- Localized path

Messages: JSON files split by namespace (auth, dashboard, pool, seo, faq, corporate, etc.). 23 namespaces per locale.

5.3 Middleware (proxy.ts)

Next.js middleware handles:

  1. www redirect: www.picks4all.com -> picks4all.com (301)
  2. i18n routing: Locale detection, NEXT_LOCALE cookie persistence, redirect via next-intl/middleware

5.4 Authentication Flow

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 idToken to POST /auth/google
  • Backend verifies with google-auth-library, creates or links user

5.5 API Client (lib/api/)

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)

5.6 Styling

  • 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: true in Next.js config (eliminates render-blocking CSS)

5.7 SEO

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

6. Database Architecture

6.1 Configuration

  • 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

6.2 Models (35 total)

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

6.3 Design Principles

  1. Normalization: Third Normal Form (3NF)
  2. Foreign keys: Enforce referential integrity
  3. Indexes: Primary keys + compound indexes on frequently queried columns
  4. Immutability: Results and published templates are append-only/versioned
  5. Soft deletes: Status fields (ACTIVE, BANNED, LEFT, ARCHIVED) instead of hard deletes
  6. Audit trail: createdAtUtc, updatedAtUtc on all tables
  7. JSON fields: Flexible data for pickJson, dataJson, pickTypesConfig
  8. Row-level locking: SELECT ... FOR UPDATE for pool capacity enforcement

6.4 Migration Strategy

  • 57+ migrations applied since 20251228053519_init_m0. Each new feature lands its own migration; the count is fluid and npx prisma migrate status is 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
    

7. Authentication & Security

7.1 JWT

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)

7.2 Google OAuth

  1. Frontend loads Google Identity Services (<Script strategy="lazyOnload">)
  2. User clicks "Sign in with Google"
  3. Google returns idToken to frontend callback
  4. Frontend sends idToken to POST /auth/google
  5. Backend verifies token with google-auth-library
  6. If email matches existing user: link accounts. Otherwise: create new user.
  7. Backend returns JWT

7.3 Corporate Activation Tokens

  • Generated with crypto.randomBytes(CRYPTO_BYTES.TOKEN)32 bytes / 64 hex chars
  • Stored in CorporateInvite model (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 as ALREADY_ACTIVATED
  • Resend rotates the token: POST /corporate/pools/:poolId/employees/:inviteId/resend invalidates 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 with SESSION_MISMATCH if the request cookie identifies a user whose email differs from invite.email)

7.4 Password Security

  • bcrypt with salt rounds = 12
  • Strength validation: min 8 chars, 1 uppercase, 1 number, 1 special character

7.5 Security Headers (Next.js)

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)

7.6 Input Protection

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

8. API Design Patterns

8.1 RESTful Endpoints

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

8.2 Response Format

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)

8.3 Single-Call Optimization

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.


9. Data Flow

9.1 Pick Submission

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 }

9.2 Result Publication (Scraper-first + API-Football fallback)

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.

9.3 Phase Advancement

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

9.4 Pool Creation

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 }

9.5 Payment & Capacity Upgrade

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.

9.6 Sales / Cuenta de Cobro Redemption (ADR-061)

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)

9.7 Server-Side Analytics DLQ

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.


10. Deployment Architecture

10.1 Production (Railway)

┌──────────────────────────────────────────────────────────────────┐
│                      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" in next.config.ts

10.2 Local Development

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 3000

Frontend:

cd frontend-next
npm install
npm run dev                       # Next.js dev server

11. Branding System

11.1 Dual Brand Files

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

11.2 Runtime Override (Backend Only)

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.

11.3 Related Configuration

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

12. Configuration

12.1 Centralized Constants (backend/src/lib/constants.ts)

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_

12.2 Environment Variables

All configurable values are loaded from environment variables. Key categories:

Category Variables
Domain SITE_DOMAIN, FRONTEND_URL
Auth JWT_SECRET
Email 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)

12.3 No Hardcoded Values

All configuration comes from:

  1. Environment variables (secrets, URLs, feature flags)
  2. Centralized constants (lib/constants.ts, lib/brand.ts, lib/siteConfig.ts)
  3. Database (platform settings, legal documents, tournament data)

Static mappings (like team flag URLs in data/teamFlags.ts) exist only as fallbacks.