// Partner promo codes (Marty, 2026-09-12): a reusable code per partner site owner that gives // members who redeem it free ad credits (earned-grade) on top of whatever they already get. // Redeemed automatically when someone joins through a link carrying ?promo=CODE, or typed into // the dashboard. One redemption per code per account; every redemption is logged; optional // cap (max uses) and expiry; codes can be switched off. Storage: MySQL promo_codes + // promo_redemptions, or DATA_DIR/promos.json. const fs = require('fs'); const path = require('path'); const db = require('./db'); let DATA_DIR = null; const norm = c => String(c || '').trim().toUpperCase().replace(/[^A-Z0-9_-]/g, '').slice(0, 24); const J = { db: { v: 1, codes: {}, redemptions: [] }, FILE() { return path.join(DATA_DIR, 'promos.json'); }, load() { try { this.db = Object.assign(this.db, JSON.parse(fs.readFileSync(this.FILE(), 'utf8'))); } catch (e) {} }, save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} }, async get(code) { return this.db.codes[code] || null; }, async list() { return Object.values(this.db.codes).sort((a, b) => b.created - a.created); }, async put(c) { this.db.codes[c.code] = Object.assign(this.db.codes[c.code] || {}, c); this.save(); return this.db.codes[c.code]; }, async uses(code) { return this.db.redemptions.filter(r => r.code === code).length; }, async redeemed(code, email) { return this.db.redemptions.some(r => r.code === code && r.email === email); }, async addRedemption(r) { this.db.redemptions.push(r); this.save(); }, async redemptions(code, n) { return this.db.redemptions.filter(r => !code || r.code === code).slice(-(n || 200)).reverse(); } }; const D = { async get(code) { const r = await db.q('SELECT * FROM promo_codes WHERE code=?', [code]); return r[0] ? row(r[0]) : null; }, async list() { return (await db.q('SELECT * FROM promo_codes ORDER BY created DESC')).map(row); }, async put(c) { await db.q('INSERT INTO promo_codes (code,credits,partner,note,max_uses,expires,active,created) VALUES (?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE credits=VALUES(credits), partner=VALUES(partner), note=VALUES(note), max_uses=VALUES(max_uses), expires=VALUES(expires), active=VALUES(active)', [c.code, c.credits, c.partner || null, c.note || null, c.maxUses || 0, c.expires || 0, c.active ? 1 : 0, c.created || Date.now()]); return this.get(c.code); }, async uses(code) { const r = await db.q('SELECT COUNT(*) n FROM promo_redemptions WHERE code=?', [code]); return Number(r[0].n); }, async redeemed(code, email) { const r = await db.q('SELECT 1 FROM promo_redemptions WHERE code=? AND email=?', [code, email]); return r.length > 0; }, async addRedemption(r) { await db.q('INSERT IGNORE INTO promo_redemptions (code,email,credits,via,ts) VALUES (?,?,?,?,?)', [r.code, r.email, r.credits, r.via, r.ts]); }, async redemptions(code, n) { const rows = code ? await db.q('SELECT * FROM promo_redemptions WHERE code=? ORDER BY ts DESC LIMIT ?', [code, Number(n) || 200]) : await db.q('SELECT * FROM promo_redemptions ORDER BY ts DESC LIMIT ?', [Number(n) || 200]); return rows.map(r => ({ code: r.code, email: r.email, credits: Number(r.credits), via: r.via, ts: Number(r.ts) })); } }; const row = r => ({ code: r.code, credits: Number(r.credits), partner: r.partner || '', note: r.note || '', maxUses: Number(r.max_uses) || 0, expires: Number(r.expires) || 0, active: !!Number(r.active), created: Number(r.created) }); const impl = () => db.enabled() ? D : J; function init(opts) { DATA_DIR = opts.dataDir; if (!db.enabled()) J.load(); } async function create(c) { const code = norm(c.code); if (!code || code.length < 3) return { error: 'Code must be 3 to 24 letters or numbers.' }; const credits = Math.floor(Number(c.credits)); if (!(credits > 0) || credits > 100000) return { error: 'Credits must be between 1 and 100,000.' }; const cur = await impl().get(code); const saved = await impl().put({ code, credits, partner: String(c.partner || '').slice(0, 80), note: String(c.note || '').slice(0, 200), maxUses: Math.max(0, Math.floor(Number(c.maxUses) || 0)), expires: c.expires ? Number(new Date(c.expires)) || 0 : 0, active: c.active !== false && c.active !== 0 && c.active !== '0', created: cur ? cur.created : Date.now() }); return { ok: true, code: saved }; } async function setActive(code, active) { const c = await impl().get(norm(code)); if (!c) return { error: 'No such code.' }; await impl().put(Object.assign(c, { active: !!active })); return { ok: true }; } // why a code cannot be used right now, or null when it can (email optional: skips the per-account check) async function check(code, email) { const k = norm(code); if (!k) return { error: 'Enter a promo code.' }; const c = await impl().get(k); if (!c || !c.active) return { error: 'That promo code is not valid.' }; if (c.expires && Date.now() > c.expires) return { error: 'That promo code has expired.' }; if (email && await impl().redeemed(k, email)) return { error: 'You already used that promo code.' }; if (c.maxUses && (await impl().uses(k)) >= c.maxUses) return { error: 'That promo code has been fully redeemed.' }; return null; } // redeem for an account; the caller adds the credits. via = 'link' | 'dashboard' async function redeem(code, email, via) { const k = norm(code); const e = String(email || '').toLowerCase(); const bad = await check(k, e); if (bad) return bad; const c = await impl().get(k); await impl().addRedemption({ code: k, email: e, credits: c.credits, via: via || 'dashboard', ts: Date.now() }); return { ok: true, credits: c.credits, code: k, partner: c.partner }; } async function adminView() { const codes = await impl().list(); for (const c of codes) c.uses = await impl().uses(c.code); return { codes, recent: await impl().redemptions(null, 100) }; } module.exports = { init, create, setActive, check, redeem, adminView, norm };