FunctionBackupViewer

Voucher Sync — Function Backup

Read-only backup of both sync functions. Copy and save to your computer in case you ever need to restore them.

🔒 These windows are read-only — you cannot accidentally change the code here. Use the Copy button to save.

🟢 Regular Sync (runs every 30 min automatically)

functions/syncVoucherUsageFromCHR

// v7 - sessions-only, time-filtered (only sessions since last run)
// At 40,000 vouchers this only touches the ~30-50 that actually got activated recently
import { createClientFromRequest } from 'npm:@base44/sdk@0.8.23';

async function chrGet(chrIp, user, pass, path) {
  const auth = btoa(`${user}:${pass}`);
  const res = await fetch(`http://${chrIp}/rest${path}`, {
    headers: { 'Authorization': `Basic ${auth}` },
    signal: AbortSignal.timeout(15000),
  });
  const text = await res.text();
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${text}`);
  return text ? JSON.parse(text) : [];
}

function parseUptime(str) {
  if (!str || str === '0') return 0;
  let total = 0;
  const d = str.match(/(\d+)d/); if (d) total += parseInt(d[1]) * 86400;
  const h = str.match(/(\d+)h/); if (h) total += parseInt(h[1]) * 3600;
  const m = str.match(/(\d+)m(?!s)/); if (m) total += parseInt(m[1]) * 60;
  const s = str.match(/(\d+)s/); if (s) total += parseInt(s[1]);
  if (total > 0) return total;
  const parts = str.split(':').map(Number);
  if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
  return 0;
}

function safeArr(raw) {
  if (Array.isArray(raw)) return raw;
  if (typeof raw === 'string') { try { const p = JSON.parse(raw); return Array.isArray(p) ? p : []; } catch { return []; } }
  return [];
}

const sleep = (ms) => new Promise(r => setTimeout(r, ms));

Deno.serve(async (req) => {
  try {
    const base44 = createClientFromRequest(req);
    const user = await base44.auth.me();
    if (!user || user.role !== 'admin') {
      return Response.json({ error: 'Unauthorized' }, { status: 403 });
    }

    const CHR_IP = '34.125.7.222';
    const CHR_USER = Deno.env.get('MIKROTIK_USERNAME') || 'admin';
    const CHR_PASS = Deno.env.get('MIKROTIK_PASSWORD') || '';

    // Cutoff = 35 minutes ago (a bit more than the 30-min schedule to avoid gaps)
    const cutoffMs = Date.now() - (35 * 60 * 1000);
    const cutoffDate = new Date(cutoffMs);
    console.log(`Sync cutoff: ${cutoffDate.toISOString()} (sessions since then)`);

    let sessions = [];
    try {
      sessions = await chrGet(CHR_IP, CHR_USER, CHR_PASS, '/user-manager/session');
    } catch(e) {
      console.warn('Sessions endpoint failed:', e.message);
      return Response.json({ error: 'Could not reach CHR sessions: ' + e.message, success: false }, { status: 500 });
    }

    console.log(`Total CHR sessions: ${sessions.length}`);

    const recentSessions = sessions.filter(s => {
      if (!s.ended && !s['end-time']) return true;
      const started = s.started || s['start-time'];
      if (started) {
        const t = new Date(started.replace(' ', 'T'));
        if (!isNaN(t) && t > cutoffDate) return true;
      }
      const ended = s.ended || s['end-time'];
      if (ended) {
        const t = new Date(ended.replace(' ', 'T'));
        if (!isNaN(t) && t > cutoffDate) return true;
      }
      return false;
    });

    console.log(`Recent sessions (since cutoff): ${recentSessions.length}`);

    const recentCodes = new Set();
    for (const s of recentSessions) {
      const code = s.user || s.username || s.name;
      if (code) recentCodes.add(code);
    }

    console.log(`Unique codes with recent activity: ${recentCodes.size}`);

    if (recentCodes.size === 0) {
      return Response.json({
        success: true,
        message: 'No new session activity since last sync — nothing to update',
        totalSessions: sessions.length,
        recentSessions: 0,
        activeCodes: 0,
        vouchersUpdated: 0,
      });
    }

    const usageMap = {};
    for (const s of sessions) {
      const code = s.user || s.username || s.name;
      if (!code || !recentCodes.has(code)) continue;
      if (!usageMap[code]) usageMap[code] = { uptime: 0, sessions: 0, last_seen: null };
      usageMap[code].sessions++;
      usageMap[code].uptime += parseUptime(s.uptime || s['session-time'] || '0');
      const t = s.ended || s.started;
      if (t && (!usageMap[code].last_seen || t > usageMap[code].last_seen)) usageMap[code].last_seen = t;
    }

    const codes = Array.from(recentCodes);
    const allVouchers = [];
    const BATCH = 20;
    for (let i = 0; i < codes.length; i += BATCH) {
      const batch = codes.slice(i, i + BATCH);
      const raw = await base44.asServiceRole.entities.Voucher.filter(
        { code: { '$in': batch } }, '-created_date', BATCH
      );
      allVouchers.push(...safeArr(raw));
      await sleep(100);
    }

    console.log(`Vouchers found in DB: ${allVouchers.length}`);

    const now = new Date().toISOString();
    const updated = [];

    for (let i = 0; i < allVouchers.length; i += 10) {
      const batch = allVouchers.slice(i, i + 10);
      const results = await Promise.all(batch.map(async (voucher) => {
        const usage = usageMap[voucher.code];
        if (!usage) return null;
        const hoursUsed = Math.round((usage.uptime / 3600) * 100) / 100;
        const hoursChanged = Math.abs(hoursUsed - (voucher.hours_used || 0)) > 0.01;
        const needsActivation = voucher.status === 'unused' && usage.sessions > 0;
        if (!hoursChanged && !needsActivation) return null;
        const data = { hours_used: hoursUsed, mikrotik_synced: true, last_sync_date: now };
        if (needsActivation) {
          data.status = 'active';
          data.activation_date = usage.last_seen ? new Date(usage.last_seen).toISOString() : now;
        }
        await base44.entities.Voucher.update(voucher.id, data);
        return { code: voucher.code, hoursUsed, sessions: usage.sessions, activated: needsActivation };
      }));
      updated.push(...results.filter(Boolean));
    }

    return Response.json({
      success: true,
      message: `Synced ${updated.length} vouchers (from ${recentCodes.size} active session codes)`,
      totalCHRSessions: sessions.length,
      recentSessions: recentSessions.length,
      activeCodes: recentCodes.size,
      vouchersFoundInDB: allVouchers.length,
      vouchersUpdated: updated.length,
      updated,
    });

  } catch(error) {
    console.error('[SYNC] Fatal:', error.message);
    return Response.json({ error: error.message, success: false }, { status: 500 });
  }
});

🟠 Emergency Force-Repair (orange button — always fixes it)

functions/repairVoucherSync

// Emergency Repair - forces correct data on ALL vouchers that appear in CHR sessions
// No delta check — overwrites everything. Safe to run anytime.
import { createClientFromRequest } from 'npm:@base44/sdk@0.8.23';

async function chrGet(chrIp, user, pass, path) {
  const auth = btoa(`${user}:${pass}`);
  const res = await fetch(`http://${chrIp}/rest${path}`, {
    headers: { 'Authorization': `Basic ${auth}` },
    signal: AbortSignal.timeout(20000),
  });
  const text = await res.text();
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${text}`);
  return text ? JSON.parse(text) : [];
}

function parseUptime(str) {
  if (!str || str === '0') return 0;
  let total = 0;
  const d = str.match(/(\d+)d/); if (d) total += parseInt(d[1]) * 86400;
  const h = str.match(/(\d+)h/); if (h) total += parseInt(h[1]) * 3600;
  const m = str.match(/(\d+)m(?!s)/); if (m) total += parseInt(m[1]) * 60;
  const s = str.match(/(\d+)s/); if (s) total += parseInt(s[1]);
  if (total > 0) return total;
  const parts = str.split(':').map(Number);
  if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
  return 0;
}

function safeArr(raw) {
  if (Array.isArray(raw)) return raw;
  if (typeof raw === 'string') { try { const p = JSON.parse(raw); return Array.isArray(p) ? p : []; } catch { return []; } }
  return [];
}

const sleep = (ms) => new Promise(r => setTimeout(r, ms));

Deno.serve(async (req) => {
  try {
    const base44 = createClientFromRequest(req);
    const user = await base44.auth.me();
    if (!user || user.role !== 'admin') {
      return Response.json({ error: 'Unauthorized' }, { status: 403 });
    }

    const CHR_IP = '34.125.7.222';
    const CHR_USER = Deno.env.get('MIKROTIK_USERNAME') || 'admin';
    const CHR_PASS = Deno.env.get('MIKROTIK_PASSWORD') || '';

    let sessions = [];
    try { sessions = await chrGet(CHR_IP, CHR_USER, CHR_PASS, '/user-manager/session'); }
    catch(e) { console.warn('Sessions endpoint failed:', e.message); }

    const usageMap = {};
    for (const s of sessions) {
      const code = s.user || s.username || s.name;
      if (!code) continue;
      if (!usageMap[code]) usageMap[code] = { uptime: 0, sessions: 0, last_seen: null };
      usageMap[code].sessions++;
      usageMap[code].uptime += parseUptime(s.uptime || s['session-time'] || '0');
      const t = s.ended || s.started;
      if (t && (!usageMap[code].last_seen || t > usageMap[code].last_seen)) usageMap[code].last_seen = t;
    }

    const codes = Object.keys(usageMap);
    console.log(`Session codes to repair: ${codes.length}`);

    if (codes.length === 0) {
      return Response.json({ success: true, message: 'No session data found on CHR — nothing to repair', repaired: 0 });
    }

    const allVouchers = [];
    const BATCH = 20;
    for (let i = 0; i < codes.length; i += BATCH) {
      const batch = codes.slice(i, i + BATCH);
      const raw = await base44.asServiceRole.entities.Voucher.filter(
        { code: { '$in': batch } }, '-created_date', BATCH
      );
      allVouchers.push(...safeArr(raw));
      await sleep(100);
    }

    console.log(`Vouchers found in DB: ${allVouchers.length}`);

    const now = new Date().toISOString();
    const repaired = [];
    const errors = [];

    for (let i = 0; i < allVouchers.length; i += 10) {
      const batch = allVouchers.slice(i, i + 10);
      await Promise.all(batch.map(async (voucher) => {
        const usage = usageMap[voucher.code];
        if (!usage) return;
        const hoursUsed = Math.round((usage.uptime / 3600) * 100) / 100;
        const data = {
          hours_used: hoursUsed,
          mikrotik_synced: true,
          last_sync_date: now,
        };
        if (usage.sessions > 0 && voucher.status === 'unused') {
          data.status = 'active';
          data.activation_date = usage.last_seen ? new Date(usage.last_seen).toISOString() : now;
        }
        try {
          await base44.entities.Voucher.update(voucher.id, data);
          repaired.push({ code: voucher.code, hoursUsed, sessions: usage.sessions, wasStatus: voucher.status, newStatus: data.status || voucher.status });
        } catch(e) {
          errors.push({ code: voucher.code, error: e.message });
        }
      }));
      await sleep(100);
    }

    return Response.json({
      success: true,
      message: `Emergency repair complete: ${repaired.length} vouchers force-corrected`,
      sessionCodes: codes.length,
      vouchersFoundInDB: allVouchers.length,
      repaired: repaired.length,
      errors: errors.length,
      details: repaired,
      errorDetails: errors,
    });

  } catch(error) {
    console.error('[REPAIR] Fatal:', error.message);
    return Response.json({ error: error.message, success: false }, { status: 500 });
  }
});

📋 How to restore if something goes wrong:

  1. Click Copy Code on the function you need to restore
  2. Go to Base44 Dashboard → Code → Functions
  3. Click the function name (syncVoucherUsageFromCHR or repairVoucherSync)
  4. Select all the existing code and paste the copied code
  5. Save — it will deploy automatically in seconds