- HTML 91.4%
- JavaScript 8.6%
|
Some checks are pending
Deploy static content to Pages / deploy (push) Waiting to run
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| .claude | ||
| .github/workflows | ||
| api | ||
| .env.example | ||
| .gitignore | ||
| .gitlab-ci.yml | ||
| .nojekyll | ||
| CHANGES_2026-05-12.txt | ||
| CLAUDE.md | ||
| index.html | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| test-agent.mjs | ||
| USER_MANUAL.md | ||
| vercel.json | ||
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_THdictionary at the top of the inline<script>block inindex.html. Each key is the trimmed English text. - A
MutationObserverwatchesdocument.body; after every render (innerHTML reassignments, textContent changes, attribute updates onplaceholder/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 viatranslateToast(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 inI18N_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.releaseMode — if (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
- AI Auto SMS toggle card — master switch (defaults off) + 3 bullet points describing what AIVA does
- Credit Balance card — amount + THB equivalent + last top-up date.
Left side:
+ Top up · 1,000 creditsbutton under the credit info. Right side: billing-lock toggle on top with🎯 Filtersstacked under it. Live "● AIVA sending live · N sent this session" indicator (visible only while toggle is on) - 🎯 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
- 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
- 3 KPI cards — deposit conversion, total deposit driven, cost per send
- ROI chart — bars (daily SMS volume) + line (ROI %), 30-day window
- Send Results chart — grouped bars success vs fail per day
- AI SMS History table — paginated 50 rows per page.
AISMS_HISTORYis generated by an IIFE at boot: every player gets 1–3 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% failedand 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 withlive: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 (replaceAISMS_DATAmock generator)/api/sms-history?limit=10— return recent sends with player UID, promo ID, sent timestamp, delivery status, attribution outcome (replaceAISMS_HISTORYmock array)/api/sms-credits— return real credit balance + last top-up timestamp;POST /api/sms-credits/topupto 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.jsfor 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-77001–P-77010); drive the demo dropdown - 50 procedural generated at boot via the IIFE just above
Object.values(PLAYERS_DB).forEach(p=>{p.periods=…})inindex.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:
- Internal tool — leave
CRON_SECRETunset, rely on the dashboard being behind the Tiamut admin login. - Public-ish — put a session/auth layer in front (Vercel Middleware
reading a session cookie, or a thin
/api/_proxythat 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.