S3 (a1): affiliate + partner plumbing
Zero out-of-pocket; everything config-flip-ready but DISABLED/organic. - BOOK IT deep links: web/src/lib/bookLinks.js + affiliateConfig.js (all books enabled:false, Impact/Partnerize param shapes documented, empty params skipped). Wired into StatStrip BookItTeaser (real anchor now) + scan hand-off links. Every book anchor renders rel="sponsored noopener noreferrer" (BOOK_LINK_REL). - Best-price marker: slateAdapter.detectBestBook (only when >=2 books post the SAME line and prices differ — absent beats wrong) + subtle signal-green dot in StatStrip. Slate.groupByGame threads the grouped per-book rows (books[]) onto PropRowProp instead of discarding them. - Partner refs: ?ref=CODE -> vyndr_ref cookie (90d, first-touch, PartnerRefCapture in layout) -> signup metadata partner_ref -> internal GET /api/partners/report/:code (requireInternalAuth; honest zeros + note until the TODO migration in docs/PARTNERS.md adds user_profiles.partner_ref — NOT run). Stripe promo-code convention: partner code == promotion code, verbatim. - Tests: +41 (2398 -> 2439, 209 suites); bookItTeaser + vyndrCoreScreens invariants updated to the new (stronger) rel contract. Web build exit 0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -187,6 +187,9 @@ app.use('/api/content', contentRoutes);
|
||||
// the public surface; the Next.js admin route proxies through with
|
||||
// the key kept server-side.
|
||||
app.use('/api/internal', internalRoutes);
|
||||
// A1 S3 — partner attribution report. Internal-key gated (router-level
|
||||
// requireInternalAuth); no Next proxy on purpose — never browser-facing.
|
||||
app.use('/api/partners', require('./routes/partners'));
|
||||
|
||||
// Session 10 — Sentry's Express error handler catches uncaught
|
||||
// errors from every route mounted above. Must come AFTER routes but
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Partner attribution report (A1 Session 3).
|
||||
*
|
||||
* GET /api/partners/report/:code — internal-only (requireInternalAuth,
|
||||
* same key as the snapshot pipeline; never browser-reachable, no Next
|
||||
* proxy on purpose). Returns per-partner attribution:
|
||||
*
|
||||
* { ok, code, signups, conversions, mrr_attributed, note? }
|
||||
*
|
||||
* Data source: public.user_profiles filtered by `partner_ref` — the
|
||||
* code a signup carried in its metadata (set by the web PartnerRefCapture
|
||||
* → AuthContext.signUp flow).
|
||||
*
|
||||
* IMPORTANT — the `partner_ref` column does NOT exist on user_profiles
|
||||
* yet. Signup metadata lands in auth.users.raw_user_meta_data, which
|
||||
* PostgREST can't query. Until the TODO migration in docs/PARTNERS.md
|
||||
* is applied (adds the column + copies it from the signup metadata in
|
||||
* handle_new_user), the select errors and this endpoint HONESTLY
|
||||
* degrades to zeros with a `note` — it never fabricates attribution.
|
||||
* Once the migration runs, real numbers flow with no code change here.
|
||||
*
|
||||
* MRR attribution uses the REAL tier prices (founder_pricing-aware) on
|
||||
* currently-active paid profiles. It is monthly recurring revenue at
|
||||
* today's price book — not lifetime value, not a projection.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { requireInternalAuth } = require('../middleware/internalAuth');
|
||||
const { getSupabaseServiceClient } = require('../utils/supabase');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(requireInternalAuth({ loopbackOnly: false }));
|
||||
|
||||
// Tier price book (matches stripeService / pricing page).
|
||||
const TIER_MRR = {
|
||||
analyst: { standard: 19.99, founder: 14.99 },
|
||||
desk: { standard: 49.99, founder: 34.99 },
|
||||
};
|
||||
|
||||
/** Same convention as web/src/lib/partnerRef.js — A-Z 0-9 - _, ≤32, uppercase. */
|
||||
function sanitizePartnerCode(raw) {
|
||||
const s = String(raw == null ? '' : raw).trim().toUpperCase();
|
||||
if (!s || s.length > 32) return null;
|
||||
return /^[A-Z0-9_-]+$/.test(s) ? s : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure report math over user_profiles rows
|
||||
* ({ tier, subscription_status, founder_pricing }).
|
||||
* signups — every profile attributed to the code
|
||||
* conversions — profiles currently on an active paid tier
|
||||
* mrr_attributed — sum of those profiles' monthly price (founder-aware)
|
||||
*/
|
||||
function buildPartnerReport(rows) {
|
||||
const list = Array.isArray(rows) ? rows : [];
|
||||
let conversions = 0;
|
||||
let mrr = 0;
|
||||
for (const r of list) {
|
||||
if (!r) continue;
|
||||
const tier = String(r.tier || 'free').toLowerCase();
|
||||
const active = String(r.subscription_status || '') === 'active';
|
||||
const prices = TIER_MRR[tier];
|
||||
if (!prices || !active) continue;
|
||||
conversions += 1;
|
||||
mrr += r.founder_pricing === true ? prices.founder : prices.standard;
|
||||
}
|
||||
return {
|
||||
signups: list.length,
|
||||
conversions,
|
||||
mrr_attributed: Math.round(mrr * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
// Injectable for tests (no network / no Supabase env needed).
|
||||
let _getClient = getSupabaseServiceClient;
|
||||
function _setClientForTests(fn) {
|
||||
_getClient = typeof fn === 'function' ? fn : getSupabaseServiceClient;
|
||||
}
|
||||
|
||||
router.get('/report/:code', async (req, res) => {
|
||||
const code = sanitizePartnerCode(req.params.code);
|
||||
if (!code) {
|
||||
return res.status(400).json({ ok: false, error: 'invalid partner code' });
|
||||
}
|
||||
try {
|
||||
const supabase = _getClient();
|
||||
const { data, error } = await supabase
|
||||
.from('user_profiles')
|
||||
.select('tier, subscription_status, founder_pricing')
|
||||
.eq('partner_ref', code);
|
||||
|
||||
if (error) {
|
||||
// Column not migrated yet (docs/PARTNERS.md TODO) — zeros, never invented.
|
||||
return res.json({
|
||||
ok: true,
|
||||
code,
|
||||
signups: 0,
|
||||
conversions: 0,
|
||||
mrr_attributed: 0,
|
||||
note: 'partner_ref is not queryable yet — apply the TODO migration in docs/PARTNERS.md to activate attribution',
|
||||
});
|
||||
}
|
||||
return res.json({ ok: true, code, ...buildPartnerReport(data) });
|
||||
} catch (err) {
|
||||
const message = err && err.message ? err.message : String(err);
|
||||
console.error('[partners/report] failed:', message);
|
||||
return res.status(500).json({ ok: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports.sanitizePartnerCode = sanitizePartnerCode;
|
||||
module.exports.buildPartnerReport = buildPartnerReport;
|
||||
module.exports._setClientForTests = _setClientForTests;
|
||||
Reference in New Issue
Block a user