Mirror of gitea.com/fargeiw/AIVA
  • HTML 91.4%
  • JavaScript 8.6%
Find a file
P fc33701397
Some checks are pending
Deploy static content to Pages / deploy (push) Waiting to run
docs: point links to askmeaiva.com and gitea (drop GitLab/GitHub refs)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 20:42:00 +04:00
.claude initial 2026-05-08 16:23:41 +04:00
.github/workflows Create static.yml 2026-05-08 11:27:57 +04:00
api Rename customer-facing "Cashback" to "Bonus" everywhere 2026-05-12 12:25:34 +04:00
.env.example Initial commit: AIVA player profile dashboard 2026-05-07 12:05:03 +04:00
.gitignore Initial commit: AIVA player profile dashboard 2026-05-07 12:05:03 +04:00
.gitlab-ci.yml Auto-reload when a newer deploy is detected 2026-05-13 14:16:05 +04:00
.nojekyll Add .nojekyll to disable Jekyll on GitHub Pages 2026-05-08 11:21:36 +04:00
CHANGES_2026-05-12.txt Scope CHANGES_2026-05-12.txt down to AISMS-only 2026-05-12 15:19:21 +04:00
CLAUDE.md docs: point links to askmeaiva.com and gitea (drop GitLab/GitHub refs) 2026-05-13 20:42:00 +04:00
index.html Player Roster: sort by P&L or Lifetime turnover 2026-05-13 16:35:21 +04:00
package-lock.json Initial commit: AIVA player profile dashboard 2026-05-07 12:05:03 +04:00
package.json Initial commit: AIVA player profile dashboard 2026-05-07 12:05:03 +04:00
README.md Swap segment-assign icon: 🛠 → 🏷️ 2026-05-13 15:00:33 +04:00
test-agent.mjs Initial commit: AIVA player profile dashboard 2026-05-07 12:05:03 +04:00
USER_MANUAL.md Separate Custom RFM grid + extend detail table + fix invalid card 2026-05-13 15:20:16 +04:00
vercel.json Initial commit: AIVA player profile dashboard 2026-05-07 12:05:03 +04:00

AIVA Campaigns Backend

Vercel serverless backend for the AIVA player profile dashboard — slot 8.1 Players in the Tiamut admin platform.

The active frontend is index.html (served at / via the rewrite in vercel.json). It is a self-contained single-file dashboard with inline CSS + JS — no build step. player_profile_pro.html is an earlier draft kept for reference; do not edit it.

The backend reads scheduled campaign configs from Vercel KV and fans out SMS (Twilio) + Email (Resend) on a cron.

Architecture

  Frontend (index.html)
      │
      │  PUT /api/campaigns         GET /api/campaigns
      ▼                                ▲
  ┌─────────────────────────────────────┐
  │           Vercel KV                  │ ◄── /api/cron (every 15 min)
  │  key: aiva:campaigns                 │           │
  └─────────────────────────────────────┘           │ matches due campaigns
                                                     │ resolves players
                                                     ├──► Twilio  (SMS)
                                                     └──► Resend  (Email)

Files

.
├── index.html                  Active frontend (single-file, ~2400 lines)
├── player_profile_pro.html     Earlier draft — reference only, do not edit
├── package.json                Dependencies
├── vercel.json                 Cron schedule + rewrites (/ → index.html)
├── .env.example                Env var template
├── api/
│   ├── cron.js                 Scheduled fan-out runner (idempotent via lastRun)
│   ├── send-email.js           Direct one-off email — accepts {uid, ...} or {to, ...}
│   ├── send-sms.js             Direct one-off SMS — accepts {uid, body} or {to, body}
│   ├── campaigns.js            Campaign config CRUD (KV)
│   ├── player.js               Single-player profile read for dashboard
│   ├── players.js              Read-only roster query (mock)
│   └── _lib/
│       ├── store.js            Vercel KV wrapper, in-memory fallback
│       ├── email.js            Resend client
│       ├── sms.js              Twilio client (Thailand-friendly — see swap note)
│       ├── players.js          ⚠ REPLACE WITH YOUR DB QUERIES
│       ├── sample-player.js    Reference response shape for /api/player
│       ├── promos.js           Promo template catalog + render()
│       └── defaults.js         Default campaigns + mock roster

Bilingual UI (English / ไทย)

The dashboard ships with a language switcher in the topbar (top-right pill). English is the default and the source. Toggling to ภาษาไทย re-renders every visible label, button, table header, status pill, toast, and modal into Thai. The choice persists per-browser to localStorage (pps75_lang).

Implementation notes for engineers:

  • All translations live in a LANG_TH dictionary at the top of the inline <script> block in index.html. Each key is the trimmed English text.
  • A MutationObserver watches document.body; after every render (innerHTML reassignments, textContent changes, attribute updates on placeholder / title / aria-label), the post-processor walks the affected subtree and substitutes any text node whose trimmed value hits the dictionary. Nothing is mutated during translation (the observer disconnects, then reconnects), so there are no feedback loops.
  • The original English value is cached on each text node via __i18nOrig, so toggling back to English restores instantly.
  • toast(msg) is wrapped via translateToast(msg) which exact-matches first, then falls through to prefix/suffix patterns for dynamic strings (e.g. Loaded thunderlotus_77โหลดแล้ว thunderlotus_77).
  • Skipped surfaces: SCRIPT/STYLE/SVG/INPUT/TEXTAREA, anything tagged with data-no-i18n, the AIVA chat panel body (user-generated content), and data-bearing element ids in I18N_SKIP_IDS (player names, UIDs, currency amounts, dates).

To add a new translatable string: put the EN→TH pair in LANG_TH. No other wiring needed.

Domain enums (Champions, VIP Gold, Cant Loose, etc.) stay English in JS state — only their display gets translated. Keep the deliberate "Cant Loose" spelling everywhere you reference it as an enum value.

Bonus model

Every bonus is calculated from three inputs: wager amount, loss amount, and VIP tier. Four scheduled campaigns. Only monthly-cashback is INDEPENDENT — the formula anchor. Every other campaign is DERIVED — a fixed % of monthly-cashback's per-player payout.

No minBonus floor on any campaign. Pure formula — inactive/dormant players who barely lose or wager receive a small (or zero) bonus, by design. The company does not pay floor amounts from its own pocket to keep dormant accounts engaged.

INDEPENDENT (monthly-cashback only):
  bonus = (lossRate × max(0, -netPL))    ← LOSS  (cashback on losses)
        + (wagerRate × turnover)          ← WAGER (rebate on activity)
        capped at:  cap × turnover         ← never pay back more than X% of wager
        × tier multiplier                  ← VIP loyalty bonus (1.0× → 2.0×)

DERIVED (weekly-cashback, pre-monthly, post-monthly):
  bonus = monthly_cashback.bonus(player) × rate
  └─ inherits tier scaling, cap-binding, and data window via composition
Campaign Type loss% wager% cap Rate Schedule
weekly-cashback derived 12.5% Every Friday, 19:00 — 1/8 of monthly per fire
monthly-cashback independent 10% 0.05% 5% Day 1, 19:00 (prev-month window) — formula anchor
pre-monthly derived 10% Day 28, 20:00
post-monthly derived 15% Day 5, 19:00

monthly-cashback is the formula anchor — (loss × 0.10) + (wager × 0.0005), an industry-standard cashback shape. AIVA retains a 5% turnover cap as the only safety. Tier multipliers stay AIVA-specific (Bronze 1.0 → VIP Diamond 2.0×).

Total per-player monthly bonus exposure for a player whose monthly cashback amount is X: weekly 4×0.125 + monthly 1.0 + pre 0.10 + post 0.15 = 1.75× X per active player per month.

Snapshot policy

Every campaign carries cutoffHoursBefore: 1. The cron should freeze each player's loss/wager 1 hour before that campaign's fire time and write the snapshot under a deterministic key (cron:snapshot:<campaignId>:<fireTimestamp>). At fire time, payouts read from the snapshot — last-minute play between cutoff and fire cannot shift the amount.

Weekly cashback fires every Friday on its own rolling 7-day window; per-week visibility comes from per-fire history records (KV cron:history:<YYYY-MM-DD>:<campaignId> writes one entry per fire), not from separate campaign config rows. Pre-monthly fires inside the month being measured for the next monthly-cashback, so its input is the most recent completed prior month.

⚠ The snapshot logic is not yet wired in api/cron.js. Today the cron reads live state at fire time. The fields and policy are documented so the wiring is a one-pass change rather than a backfill + behavior change.

Delivery mode

The cron currently does not honor delivery mode — it sends SMS/email for all eligible players regardless. The fields persist so a future wallet-credit integration is a one-line cron change instead of a data migration.

Release mode (auto vs manual) + AIVA Rewards

Two orthogonal switches gate when bonuses actually leave the casino:

  • state.aiAutopilot (boolean, page-level) — the master switch on the 🎁 AIVA Rewards tab. Always defaults to OFF at every page open (session-only state, not persisted) so the customer always lands on a clean opt-in. When off, the entire tab content (qstrip, breakdown, top recipients) is hidden — only the autopilot card itself shows. When on, AIVA runs all calculations, scheduling, and decisions automatically.
  • c.releaseMode: 'auto' | 'manual' per campaign — controls whether the release of money on that campaign happens automatically (autopilot fires it) or waits for human approval. Defaults to 'manual' for safety. Backfilled into pre-existing localStorage campaigns on boot.

Behavior matrix:

autopilot releaseMode Outcome
off Nothing fires. Page is empty except the master switch.
on auto Cron fires this campaign automatically at schedule.
on manual Cron computes the snapshot but skips fan-out; the ▶ Release button on the per-campaign row triggers the actual send via /api/cron?force=<id>.

Wiring TODO in api/cron.js: respect c.releaseModeif (c.releaseMode === 'manual') skip auto-fan-out (still write the snapshot for the upcoming manual release). The frontend already calls /api/cron?force=<id> from the Release button, which bypasses the schedule check.

Pending Releases card

When AIVA Rewards is on AND ≥1 active campaign is in Manual release mode, a dedicated card appears between the autopilot toggle and the qstrip, listing each pending campaign with projected ฿, recipient count, next fire time, and a ▶ Approve & Release button. Rendered by renderPendingReleases(breakdown). Reuses runCampaignNow for the actual fan-out trigger.

Recent Fires card

Per-fire history is shown in a card after Top Bonus Recipients — last 8 fires across all campaigns, with date, campaign, recipient count, total paid, and the auto-credit / claim-required split. Today this reads from the CRON_HISTORY constant (mock data); when KV history logging is wired in api/cron.js, swap renderPayoutHistory to fetch from /api/cron-history.

Reverse-correlation insight (campaign-responder)

The 17th insight generator. Scans p.comms for a deposit message tagged after sms|email|promo and pairs it with the most recent matching campaign send. Surfaces what's working — which players responded to which channel. Weight 78. In production this becomes a temporal join against cron history instead of regex on the demo comm log.

Delivery: claim-required only

Every bonus is claim-required — the customer must open the SMS/email and click claim before the bonus credits. There is no auto-credit option in the UI. Each campaign carries defaultDelivery: 'claim-required' and deliveryBySegment: {} (always). The editor surfaces a static "Delivery" panel explaining the policy; the radio + per-segment override grid was removed when the product committed to claim-required across the board.

resolveDelivery(campaign, segment) still exists as defensive code — if someone sets defaultDelivery: 'auto-credit' via direct localStorage/KV edit, the renderer respects it. But no UI path produces that config.

Total / Claimed / Unclaimed accounting

The qstrip on the AIVA Rewards tab shows three cells:

Cell Meaning
Total bonus Projected (Current tab) or actual (Past tab) bonus across active campaigns × eligible players
📨 Already claimed total × CLAIM_RATE for Current; sum of CRON_HISTORY[h].claimed for Past
Unclaimed total claimed — bonus that players left unclaimed (forfeited or pending claim)

CLAIM_RATE = 0.65 (constant in index.html) — typical Thai-market claim rate for casino reload campaigns. In production, recompute weekly from cron history's claimed/totalPaid ratio. The breakdown table, Recent Fires card, and Top Bonus Recipients all mirror this split.

AIVA Rewards tab — temporal breakdown

The AIVA Rewards tab shows monthly bonus expense across two sub-tabs at the top of the per-campaign breakdown card:

Tab Source
Past (paid) Real actuals from cron history — what's already been paid. The Release column shows ● Settled on this tab.
Current (pending) This month's projected payout — rolls into Past once each fire completes. The Release column shows the Auto/Manual pill (and a ▶ Release button when Manual + Active).

For each campaign Current is computed:

current_amount = sum(computeBonus(player).amount for player in eligible)
               × monthlyFires       // weekly = 4, monthly = 1

Past is read from KV (cron:history:<YYYY-MM-DD>:<campaignId>{ totalPaid, recipientCount }), summed across the relevant payment window. Each successful cron fan-out should write its actuals there inside api/cron.js, so amounts on the Current tab roll into Past once paid.

The qstrip total / auto-credit / claim-required cells, the per-row totals, and the top-recipient table all scale together when the tab is changed.

Demo state: the cron history endpoint is not yet wired. Until it is, the Past tab falls back to a placeholder factor (PAYOUT_TAB_FACTORS.past = 0.93) on Current's number. Replace this with the real KV fetch before showing the dashboard to anyone who'll act on the numbers.

AI SMS (slot 8.3)

A separate sidebar slot for the automated SMS engine. When AI Auto SMS is on, AIVA continuously selects the highest-value recipients, picks a profile-matched promotion for each, and dispatches — until credits run out. Always defaults to OFF on every page open (session-only state).

Credit model

Constant Value Notes
state.aismsCredits starts at 0 Top up adds 1,000 per click
state.aismsBilling starts false Billing lock — must be unlocked before AISMS can send
AISMS_CREDIT_PER_SMS 0.5 Each SMS consumes 0.5 credit
AISMS_THB_PER_CREDIT 13 1,000 credits ≈ ฿13,000
AISMS_TICK_MS 1000 Demo ticker interval — 1 SMS per second
AISMS_PER_TICK 1 SMS sent per tick — 0.5 credits/sec
state.aismsSentUids [] (seeded from the top 10 most-recent AISMS_HISTORY rows) UIDs already auto-texted; each player gets at most one auto SMS per session

AI Auto SMS is the master toggle (state.aiSms); the SMS Credit toggle (state.aismsBilling) is a kill-switch overlay. Sending happens only when both are on, credits are sufficient, and the pending queue (filtered roster minus aismsSentUids) is non-empty. Locking the credit toggle while AISMS is sending pauses the ticker immediately but keeps state.aiSms = true — unlocking auto-resumes. aismsCanSend() encodes the credit/billing check; aismsHasPending() adds the per-player gate; syncAISMSTicker() AND's both and starts/stops the ticker per state. The ticker is purely for the live counter UX — in production the real cost happens on the cron / Twilio side per fan-out batch. When the balance can't cover the next tick, AI Auto SMS auto-pauses with a toast. Each player is texted once. When the pending queue empties, the ticker stops, the status flips to Idle, and state.aiSms stays on; a filter change that brings a fresh untexted player into scope auto-resumes sending. topUpCredit() adds 1,000 credits and stamps today as the new last-top-up.

Status pill — three-state (Off / Idle / On)

State Pill color Meaning
Off grey master toggle off
On green (--pri) engaged AND sending live (credits unlocked, available, queue non-empty)
Idle yellow (--amber) engaged AND not firing right now (queue empty, credit locked, or insufficient credits)

The .idle class on .autopilot-card (slot 8.3 only — never set on the 8.2 Rewards autopilot) is what swaps the pill background to amber via .autopilot-card.on.idle .autopilot-title .ap-status. The same .idle class also gets toggled on #aisms-credit so the SMS Credit pill mirrors the yellow. The description text under the pill narrates which idle sub-state the user is in.

AI Top 10% recommendation filter

The Filters popout includes a special toggle at the top — 🤖 AI recommends top 10% players — that ranks every player by a desirability score and intersects the top 10% with the other chip filters:

desirability = aivaScore × 1.2
             + recencyRadar × 35
             + log₁₀(recentLoss + 1) × 8
             + log₁₀(lifetimeTurnover + 1) × 4

Implemented as aismsDesirability(player) + aismsTop10Set() → intersected inside aismsFilteredPlayers() when state.aismsAIRecommend === true. Top 10% of 60 = 6 players.

Filters: popout modal

The 🎯 Filters card is a centered popout — fixed positioning, dimmed/blurred backdrop, soft pop-in animation, Esc / × / backdrop-click to close. Body scroll locks while open so the user doesn't lose position underneath. Focus moves to the close button on open for keyboard cycling. Hand-off styling: .card.collapsible.expanded becomes fixed + centered; .filter-backdrop.show reveals the overlay.

Confirm → auto-start. Pressing ✓ Confirm in the popout closes it and, if state.aiSms === false, also calls toggleAISMS() to engage the master switch. One-click "set filters and start sending". The existing credit check inside toggleAISMS still refuses with the "No credits — top up first" toast if appropriate.

Layout

  1. AI Auto SMS toggle card — master switch (defaults off) + 3 bullet points describing what AIVA does
  2. Credit Balance card — amount + THB equivalent + last top-up date. Left side: + Top up · 1,000 credits button under the credit info. Right side: billing-lock toggle on top with 🎯 Filters stacked under it. Live "● AIVA sending live · N sent this session" indicator (visible only while toggle is on)
  3. 🎯 Filters card (collapsible — opens via the Filters button on the Credit card, hidden by default). Six filter dimensions:
    • Segments — 11 RFM checkboxes
    • VIP tiers — 5 chips (Bronze → Diamond)
    • Loss tier — 5 chips (Whale 👑 / Platinum 💎 / Gold 🥇 / Silver 🥈 / Bronze 🥉) + "Top losers only" preset (loss ≥ ฿20k)
    • Online status — Online now / Offline
    • Min lifetime turnover (THB) — numeric input
    • Last active — Today / This week / This month / 30+ days idle
  4. Date-range filter bar — datetime-local From/To, Search/Clear, quick-range pill (Today / Yesterday / This week / Last week / This month / Last month), AI SMS History scroll-to button
  5. 3 KPI cards — deposit conversion, total deposit driven, cost per send
  6. ROI chart — bars (daily SMS volume) + line (ROI %), 30-day window
  7. Send Results chart — grouped bars success vs fail per day
  8. AI SMS History table — paginated 50 rows per page. AISMS_HISTORY is generated by an IIFE at boot: every player gets 13 entries with an archetype-matched promo (whales → VIP Diamond / high-roller, at-risk → win-back, new → welcome reload, etc.), spread across the last 30 days, with status ~93% delivered / 7% failed and outcomes ~55% deposited / 25% pending / 20% no-response. Deposit amounts scale by VIP tier. Live updates — every time the ticker sends an SMS, a new row is prepended with live:true, which adds a pulsing green-bordered highlight that clears after 4s. Click any row → opens the player profile.

aismsFilteredPlayers() AND-combines all six chip filters plus the optional AI Top 10% intersection into the recipient set the cron will use when AI Auto SMS fires. Defaults to all-selected so the initial state targets the full roster (70 players, or 7 if the top-10% toggle is on).

Wiring TODO

  • /api/sms-stats?from=…&to=… — return daily series for the ROI + Send Results charts (replace AISMS_DATA mock generator)
  • /api/sms-history?limit=10 — return recent sends with player UID, promo ID, sent timestamp, delivery status, attribution outcome (replace AISMS_HISTORY mock array)
  • /api/sms-credits — return real credit balance + last top-up timestamp; POST /api/sms-credits/topup to charge a billing source
  • AI recipient selector — when toggle goes on, the cron should call AIVA's selector (Anthropic API) to pick the next batch of recipients keyed by (segment × archetype × signals) and dispatch via Twilio. The frontend state (state.aiSms) should be persisted to KV so it survives reloads.

Setup

1. Provider accounts (5 minutes)

  • Resend — sign up at https://resend.com, copy API key. Free tier covers 3,000 emails/month. Verify a sender domain to send from noreply@yourdomain.com.
  • Twilio — sign up at https://twilio.com, get Account SID + Auth Token from the dashboard, then provision a phone number.
  • Vercel KV — in Vercel dashboard → Storage → Create Database → KV. Link to your project.

🇹🇭 If you're sending only to Thai numbers and want lower cost + native sender ID handling, swap api/_lib/sms.js for a Thaibulksms client (same function signature, no other code changes).

2. Install + configure

npm install
cp .env.example .env.local
# Fill in RESEND_API_KEY, TWILIO_*, KV_*, CRON_SECRET, ANTHROPIC_API_KEY

3. Local dev

vercel link              # link to your Vercel project
vercel env pull .env.local  # pulls KV creds from Vercel automatically
vercel dev               # runs locally on http://localhost:3000

4. Deploy

vercel --prod

Cron schedule

Default: */15 * * * * (every 15 min). Vercel cron tiers:

Plan Min frequency
Hobby Once per day
Pro Every minute
Team Every minute

If you're on Hobby, change vercel.json to 0 8 * * * (daily at 08:00 UTC) and the campaign engine will still work — just one batch per day.

Idempotency: cron.js stamps c.lastRun after fan-out. The lastRun >= mostRecentTarget(c, now) check in shouldFire prevents double-fires within a window — preserve this when modifying scheduling logic.

Custom segments

Operators can save their own named segments on the Segmentation tab alongside the 11 built-in RFM cards. Each custom segment is built from ten thresholds mirroring the inputs to AIVA's player-ranking formula (aismsDesirability): min AIVA score · max days since active · min recent loss · min lifetime turnover · min avg deposit · five radar dimensions (frequency / monetary / loyalty / engagement / risk — the last as a max-ceiling). Defaults match everyone; tighten what matters.

A player can belong to any number of custom segments — collections are independent. Each segment also carries manualIncludes: [uid] and manualExcludes: [uid] arrays so operators can override the criteria match per-player. Every roster row has a 🏷️ button (with a count badge for already-assigned players) that opens the per-player assignment modal; togglePlayerInSegment(uid, segId) walks the include/exclude logic. Reason badges (criteria / manual / excluded) annotate each row in the modal.

The same per-row modal also shows the 11 RFM archetypes as checkboxes — a player can belong to multiple archetypes simultaneously (≥1 required). The primary archetype stays in p.period30dBase.archetype (so the roster badge and single- archetype display paths keep working); additional memberships go in p.extraArchetypes. playerArchetypes(p), playerInArchetype(p, arch), and togglePlayerArchetype(uid, arch) are the helpers; renderSegments / the roster filter / aismsFilteredPlayers all walk the full membership set so multi- tagged players count in every segment they belong to. AIVA's default of one archetype per player is preserved via p.aivaArchetype and the "revert to AIVA's pick" link in the modal.

Persistence: custom segments in pps75_custom_segments; archetype multi-memberships in pps75_archetype_overrides. Both localStorage, no backend.

Deploy freshness (auto-reload on stale HTML)

GitLab Pages defaults to Cache-Control: max-age=600. Operators were seeing stale HTML for up to 10 minutes after a deploy. The CI build injects $CI_COMMIT_SHA into a <meta name="build-version"> tag and writes a sibling public/version.txt. A small script at the top of <head> fetches version.txt with cache: 'no-store' on every page load and location.replace()s with a ?v=<sha> cache-buster when the SHAs differ. sessionStorage guards against reload loops. Once an operator does one final hard-refresh to pick up the script, every subsequent visit auto-rolls to the latest deploy.

Demo roster

The frontend ships with 70 players in PLAYERS_DB:

  • 20 hand-crafted — 10 original RFM archetypes + 10 Unconverted Leads (P-77001P-77010); drive the demo dropdown
  • 50 procedural generated at boot via the IIFE just above Object.values(PLAYERS_DB).forEach(p=>{p.periods=…}) in index.html. Templates per archetype define typical ranges so each procedural player has a coherent fin/RFM/recency profile and renders correctly across roster / profile / segments / bonus payouts. Seed is fixed (_seed=20260508) so the procedural set is stable across reloads.

To grow / shrink the procedural roster, change the count in the IIFE (for(let i=0;i<50;i++)) or adjust the DISTRIBUTION weights to lean more toward the production data shape (Hibernating-dominant).

Replacing the mock player DB

Edit api/_lib/players.js. Implement getPlayersBySegment(segments) against your real DB. It must return an array of:

{ uid: string, name: string, phone: string, email: string, archetype: string }

archetype must match one of the 11 RFM segments used by the frontend (note the deliberate "Cant Loose" misspelling — it matches the real CRM):

Champions · Loyal Customers · Potential Loyalists · New Customers ·
Promising · Need Attention · About To Sleep · At Risk · Cant Loose ·
Hibernating

segments will either contain 'all' (return everyone) or specific archetype names.

Example (Postgres with pg):

import pg from 'pg';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });

export async function getPlayersBySegment(segments) {
  if (!segments || segments.includes('all')) {
    const { rows } = await pool.query('SELECT uid, name, phone, email, archetype FROM players WHERE active=true');
    return rows;
  }
  const { rows } = await pool.query(
    'SELECT uid, name, phone, email, archetype FROM players WHERE active=true AND archetype = ANY($1::text[])',
    [segments]
  );
  return rows;
}

Testing

# Bearer auth (only required if CRON_SECRET is set; endpoints are open when unset)
SECRET="Bearer $(grep CRON_SECRET .env.local | cut -d= -f2)"

# Get the default sample player (P-20481)
curl http://localhost:3000/api/player?uid=P-20481

# Try a UID that's not in your DB yet → 404
curl http://localhost:3000/api/player?uid=P-99999

# Get current campaigns
curl http://localhost:3000/api/campaigns

# Send a test email (uid form preferred — server resolves real contact)
curl -X POST http://localhost:3000/api/send-email \
  -H "Content-Type: application/json" -H "Authorization: $SECRET" \
  -d '{"uid":"P-20481","subject":"Test","body":"hello"}'

# Send a test SMS by uid
curl -X POST http://localhost:3000/api/send-sms \
  -H "Content-Type: application/json" -H "Authorization: $SECRET" \
  -d '{"uid":"P-20481","body":"hello from AIVA"}'

# Trigger cron now (skip schedule check / fire a specific campaign)
curl "http://localhost:3000/api/cron?force=weekly-reload" -H "Authorization: $SECRET"

# Dry-run (no actual sends, just compute targets)
curl "http://localhost:3000/api/cron?force=weekly-reload&dryRun=true" -H "Authorization: $SECRET"

Frontend ↔ backend status

Three wiring swaps. Two are already applied in index.html; one is intentionally not.

A) Player profile reads (NOT applied — intentional)

index.html's client-side PLAYERS_DB carries a richer profile shape than api/_lib/sample-player.js (finDetails, full comms, period30dBase with radar/RFM/fin breakdown, full heatmap). lookupPlayer is therefore not wired to /api/player yet — doing so would break every player except P-20481 and drop fields the dashboard renders.

To go live: align SAMPLE_P_20481 (and the production getPlayerByUid query) with the frontend's full shape, then replace lookupPlayer's body:

async function lookupPlayer(uid){
  uid = String(uid||'').trim().toUpperCase();
  if(!uid) return null;
  if(PLAYERS_DB[uid] && PLAYERS_DB[uid].periods) return PLAYERS_DB[uid];
  try {
    const r = await fetch('/api/player?uid='+encodeURIComponent(uid));
    if(!r.ok) return null;
    const p = await r.json();
    p.periods = derivePeriods(p.period30dBase, p.memberSince);
    PLAYERS_DB[uid] = p;
    return p;
  } catch(e) { return null; }
}

Also make loadPlayer and the init IIFE handle the now-async lookupPlayer (add await in front of lookupPlayer(...) calls).

B) Campaign configs (UI ↔ KV sync — APPLIED)

index.html boots from localStorage, then refreshes from /api/campaigns once it responds. Saves go to KV via PUT; localStorage stays in sync as an offline fallback. Backfill on boot patches pre-existing stored campaigns with new fields (e.g. defaultDelivery, deliveryBySegment).

C) Real sends (APPLIED)

runCampaignNow POSTs to /api/cron?force=<id>. Per-player SMS / Email modals POST {uid, body} to /api/send-sms and /api/send-email; the backend resolves the real contact via getContactByUid. The masked demo phone numbers in the frontend would fail Twilio validation — server-side resolution is what makes the modal work in production.

Auth boundary

/api/cron, /api/campaigns (writes), /api/send-sms, /api/send-email all check Authorization: Bearer ${CRON_SECRET} only when CRON_SECRET is set in env. When unset (demo mode), endpoints are open.

A static HTML page can't safely hold a secret. Two production paths:

  1. Internal tool — leave CRON_SECRET unset, rely on the dashboard being behind the Tiamut admin login.
  2. Public-ish — put a session/auth layer in front (Vercel Middleware reading a session cookie, or a thin /api/_proxy that injects the bearer). Don't embed the secret in static HTML.

Cost estimates

For 10,000 players / weekly campaign / both channels:

Item Volume Approx cost
Resend email 40k/month $20 (Pro plan)
Twilio SMS (TH) 40k/month ~฿24,000 / ~$680
Vercel KV low traffic Free tier sufficient
Vercel Pro cron $20/month

Switch SMS to Thaibulksms for Thailand-only and the SMS bill drops by ~60%.

If/when auto-credit delivery is wired through to a real wallet API, the cost shape shifts: claim-required keeps a self-selection filter (effective payout = eligible × claim_rate × bonus), while auto-credit pays out 100% of the eligible × bonus. AIVA defaults all campaigns to claim-required, so 100% of payouts are gated by a player action — opt into auto-credit per-segment in the editor only when frictionless retention is the explicit goal.