Files
linkspin/promos.js
T

75 lines
6.1 KiB
JavaScript

// 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,funder) VALUES (?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE funder=VALUES(funder), 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(), c.funder || null]);
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, funder: r.funder || null, 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, funder: c.funder ? String(c.funder).toLowerCase() : (cur && cur.funder) || null, 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, funder: c.funder || null };
}
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) };
}
async function byFunder(email) { return (await impl().list()).filter(c => c.funder === String(email || '').toLowerCase()); }
module.exports = { init, create, setActive, check, redeem, adminView, norm, byFunder };