ccdbc47642
Replaced polling with a single queued cursor worker, timeout recovery, backoff, deduplication, chronological insertion, and capped DOM history in cyberchat-v4.js. Removed Groups UI, API, configuration, tier features, admin controls, and archive exposure. Legacy group records remain stored but inaccessible. Added configurable polling limits in config.json.
239 lines
17 KiB
PHP
239 lines
17 KiB
PHP
<?php
|
|
|
|
function handleAdminPlatformAction(string $action): ?string {
|
|
if ($action === 'save_platform_services') {
|
|
setConfigValues([
|
|
'subscriptions.enabled' => !empty($_POST['subscriptions_enabled']),
|
|
'mailgun.api_key' => trim((string)($_POST['mailgun_api_key'] ?? '')),
|
|
'mailgun.domain' => trim((string)($_POST['mailgun_domain'] ?? '')),
|
|
'mailgun.from' => trim((string)($_POST['mailgun_from'] ?? '')),
|
|
'mailgun.api_base' => trim((string)($_POST['mailgun_api_base'] ?? 'https://api.mailgun.net/v3')),
|
|
'stripe.secret_key' => trim((string)($_POST['stripe_secret_key'] ?? '')),
|
|
'stripe.webhook_secret' => trim((string)($_POST['stripe_webhook_secret'] ?? '')),
|
|
'stripe.success_url' => trim((string)($_POST['stripe_success_url'] ?? '')),
|
|
'stripe.cancel_url' => trim((string)($_POST['stripe_cancel_url'] ?? '')),
|
|
'stripe.portal_return_url' => trim((string)($_POST['stripe_portal_return_url'] ?? '')),
|
|
'database.mysql.enabled' => !empty($_POST['mysql_enabled']),
|
|
'database.mysql.auto_import' => !empty($_POST['mysql_auto_import']),
|
|
'database.mysql.dsn' => trim((string)($_POST['mysql_dsn'] ?? '')),
|
|
'database.mysql.username' => trim((string)($_POST['mysql_username'] ?? '')),
|
|
'database.mysql.password' => (string)($_POST['mysql_password'] ?? ''),
|
|
'database.mysql.sync_interval_seconds' => max(60, (int)($_POST['mysql_sync_interval'] ?? 900)),
|
|
]);
|
|
return 'Platform service settings saved.';
|
|
}
|
|
|
|
if ($action === 'save_plans') {
|
|
$db = getDB();
|
|
$plans = $_POST['plans'] ?? [];
|
|
$boolFeatures = [
|
|
'voice_messages', 'recording_status', 'auto_voice_send', 'auto_voice_play', 'username_color',
|
|
'text_export', 'voice_archive', 'voice_export', 'email_2fa',
|
|
];
|
|
$db->beginTransaction();
|
|
try {
|
|
$update = $db->prepare("UPDATE plans SET name=?,description=?,price_cents=?,currency=?,
|
|
billing_interval=?,stripe_price_id=?,active=?,sort_order=?,updated_at=? WHERE id=?");
|
|
$upsertFeature = $db->prepare("INSERT INTO plan_features (plan_id,feature_key,value_json) VALUES (?,?,?)
|
|
ON CONFLICT(plan_id,feature_key) DO UPDATE SET value_json=excluded.value_json");
|
|
foreach ($plans as $id => $plan) {
|
|
$id = (int)$id;
|
|
$name = trim((string)($plan['name'] ?? ''));
|
|
if ($id < 1 || $name === '') continue;
|
|
$update->execute([
|
|
$name,
|
|
trim((string)($plan['description'] ?? '')),
|
|
max(0, (int)($plan['price_cents'] ?? 0)),
|
|
strtolower(substr(trim((string)($plan['currency'] ?? 'usd')), 0, 3)),
|
|
in_array($plan['billing_interval'] ?? '', ['day', 'week', 'month', 'year'], true)
|
|
? $plan['billing_interval'] : 'month',
|
|
trim((string)($plan['stripe_price_id'] ?? '')) ?: null,
|
|
!empty($plan['active']) ? 1 : 0,
|
|
(int)($plan['sort_order'] ?? 0),
|
|
time(),
|
|
$id,
|
|
]);
|
|
foreach ($boolFeatures as $key) {
|
|
$upsertFeature->execute([$id, $key, json_encode(!empty($plan['features'][$key]))]);
|
|
}
|
|
$upsertFeature->execute([$id, 'text_archive_days', json_encode(max(30, min(3650, (int)($plan['features']['text_archive_days'] ?? 30))))]);
|
|
}
|
|
$db->commit();
|
|
} catch (Throwable $e) {
|
|
if ($db->inTransaction()) $db->rollBack();
|
|
throw $e;
|
|
}
|
|
return 'Plans, prices, limits, and feature assignments saved.';
|
|
}
|
|
|
|
if ($action === 'create_plan') {
|
|
$slug = strtolower(trim((string)($_POST['slug'] ?? '')));
|
|
$name = trim((string)($_POST['name'] ?? ''));
|
|
if (!preg_match('/^[a-z0-9_-]{2,30}$/', $slug) || $name === '') {
|
|
throw new RuntimeException('Plan slug or name is invalid.');
|
|
}
|
|
$db = getDB();
|
|
$db->prepare("INSERT INTO plans
|
|
(slug,name,description,price_cents,currency,billing_interval,active,sort_order,created_at,updated_at)
|
|
VALUES (?,?,?,0,'usd','month',1,50,?,?)")
|
|
->execute([$slug, $name, trim((string)($_POST['description'] ?? '')), time(), time()]);
|
|
$id = (int)$db->lastInsertId();
|
|
$features = [
|
|
'voice_messages' => true, 'recording_status' => false, 'auto_voice_send' => false,
|
|
'auto_voice_play' => false, 'username_color' => false, 'text_archive_days' => 30,
|
|
'text_export' => true, 'voice_archive' => false, 'voice_export' => false, 'email_2fa' => false,
|
|
];
|
|
$insert = $db->prepare("INSERT INTO plan_features (plan_id,feature_key,value_json) VALUES (?,?,?)");
|
|
foreach ($features as $key => $value) $insert->execute([$id, $key, json_encode($value)]);
|
|
return "Plan '$name' created.";
|
|
}
|
|
|
|
if ($action === 'run_mysql_mirror') {
|
|
require_once ROOT_DIR . '/lib/mysql_mirror.php';
|
|
$result = mirrorAllSQLiteDatabases();
|
|
return "MySQL mirror completed: {$result['databases']} database(s), {$result['rows']} row(s).";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function adminFeatureLabel(string $key): string {
|
|
return ucwords(str_replace('_', ' ', $key));
|
|
}
|
|
|
|
function renderAdminPlatformTab(): void {
|
|
$plans = plans(false);
|
|
$db = getDB();
|
|
$subscribers = $db->query("SELECT u.id,u.username,u.email,u.email_verified_at,u.subscription_status,
|
|
u.subscription_period_end,u.cancel_at_period_end,p.name AS plan_name,u.stripe_customer_id,u.manual_plan_id
|
|
FROM users u LEFT JOIN plans p ON p.id=COALESCE(u.manual_plan_id,u.plan_id)
|
|
ORDER BY (u.subscription_status IN ('active','trialing','past_due')) DESC,u.created_at DESC LIMIT 500")->fetchAll();
|
|
$stats = [
|
|
'visits_24h' => (int)$db->query("SELECT COUNT(*) FROM visitor_events WHERE event_type='visit' AND created_at>" . (time() - 86400))->fetchColumn(),
|
|
'unique_24h' => (int)$db->query("SELECT COUNT(DISTINCT ip_hash) FROM visitor_events WHERE created_at>" . (time() - 86400))->fetchColumn(),
|
|
'paid' => (int)$db->query("SELECT COUNT(*) FROM users WHERE subscription_status IN ('active','trialing','past_due')")->fetchColumn(),
|
|
];
|
|
$events = $db->query("SELECT event_type,COUNT(*) AS total FROM visitor_events
|
|
WHERE created_at>" . (time() - 2592000) . " GROUP BY event_type ORDER BY total DESC")->fetchAll();
|
|
$featureKeys = [
|
|
'voice_messages', 'recording_status', 'auto_voice_send', 'auto_voice_play',
|
|
'username_color', 'text_archive_days', 'text_export', 'voice_archive', 'voice_export', 'email_2fa',
|
|
];
|
|
?>
|
|
<div class="page-head"><h1>PLANS & PLATFORM</h1>
|
|
<p>// pricing, entitlements, subscriptions, email, Stripe, database mirroring, and usage</p></div>
|
|
|
|
<div class="stat-grid">
|
|
<div class="stat-card green"><div class="stat-num"><?= $stats['visits_24h'] ?></div><div class="stat-label">Visits / 24h</div></div>
|
|
<div class="stat-card blue"><div class="stat-num"><?= $stats['unique_24h'] ?></div><div class="stat-label">Unique / 24h</div></div>
|
|
<div class="stat-card violet"><div class="stat-num"><?= $stats['paid'] ?></div><div class="stat-label">Paid Subscribers</div></div>
|
|
</div>
|
|
|
|
<form method="post">
|
|
<input type="hidden" name="csrf" value="<?= csrfToken() ?>">
|
|
<input type="hidden" name="action" value="save_platform_services">
|
|
<div class="panel"><div class="panel-head"><span class="panel-title">// SUBSCRIPTION VISIBILITY</span></div>
|
|
<div class="panel-body"><label class="cb-row"><input type="checkbox" name="subscriptions_enabled" <?= getConfigVal('subscriptions.enabled', true) ? 'checked' : '' ?>>
|
|
<span>Allow and display new subscriptions. When off, existing subscribers can still manage billing.</span></label></div></div>
|
|
|
|
<div class="panel"><div class="panel-head"><span class="panel-title">// MAILGUN</span></div><div class="panel-body">
|
|
<div class="form-row3">
|
|
<div class="field"><label>API Key</label><input type="password" name="mailgun_api_key" value="<?= esc(getConfigVal('mailgun.api_key', '')) ?>"></div>
|
|
<div class="field"><label>Domain</label><input name="mailgun_domain" value="<?= esc(getConfigVal('mailgun.domain', '')) ?>"></div>
|
|
<div class="field"><label>From</label><input name="mailgun_from" value="<?= esc(getConfigVal('mailgun.from', '')) ?>"></div>
|
|
</div>
|
|
<div class="field"><label>API Base</label><input name="mailgun_api_base" value="<?= esc(getConfigVal('mailgun.api_base', 'https://api.mailgun.net/v3')) ?>"></div>
|
|
</div></div>
|
|
|
|
<div class="panel"><div class="panel-head"><span class="panel-title">// STRIPE</span></div><div class="panel-body">
|
|
<div class="form-row">
|
|
<div class="field"><label>Secret Key</label><input type="password" name="stripe_secret_key" value="<?= esc(getConfigVal('stripe.secret_key', '')) ?>"></div>
|
|
<div class="field"><label>Webhook Secret</label><input type="password" name="stripe_webhook_secret" value="<?= esc(getConfigVal('stripe.webhook_secret', '')) ?>"></div>
|
|
</div>
|
|
<div class="form-row3">
|
|
<div class="field"><label>Success URL</label><input name="stripe_success_url" value="<?= esc(getConfigVal('stripe.success_url', '')) ?>"></div>
|
|
<div class="field"><label>Cancel URL</label><input name="stripe_cancel_url" value="<?= esc(getConfigVal('stripe.cancel_url', '')) ?>"></div>
|
|
<div class="field"><label>Portal Return URL</label><input name="stripe_portal_return_url" value="<?= esc(getConfigVal('stripe.portal_return_url', '')) ?>"></div>
|
|
</div>
|
|
<p class="dim">Webhook endpoint: <span class="mono"><?= esc(appBaseUrl() . '/api/stripe-webhook.php') ?></span></p>
|
|
</div></div>
|
|
|
|
<div class="panel"><div class="panel-head"><span class="panel-title">// OPTIONAL MYSQL / MARIADB MIRROR</span></div><div class="panel-body">
|
|
<label class="cb-row"><input type="checkbox" name="mysql_enabled" <?= getConfigVal('database.mysql.enabled', false) ? 'checked' : '' ?>><span>Enable MySQL mirror</span></label>
|
|
<label class="cb-row"><input type="checkbox" name="mysql_auto_import" <?= getConfigVal('database.mysql.auto_import', false) ? 'checked' : '' ?>><span>Automatically import all SQLite databases on the configured interval</span></label>
|
|
<div class="form-row3">
|
|
<div class="field"><label>PDO DSN</label><input name="mysql_dsn" value="<?= esc(getConfigVal('database.mysql.dsn', '')) ?>" placeholder="mysql:host=localhost;dbname=cyberchat;charset=utf8mb4"></div>
|
|
<div class="field"><label>Username</label><input name="mysql_username" value="<?= esc(getConfigVal('database.mysql.username', '')) ?>"></div>
|
|
<div class="field"><label>Password</label><input type="password" name="mysql_password" value="<?= esc(getConfigVal('database.mysql.password', '')) ?>"></div>
|
|
</div>
|
|
<div class="field"><label>Sync Interval Seconds</label><input type="number" min="60" name="mysql_sync_interval" value="<?= (int)getConfigVal('database.mysql.sync_interval_seconds', 900) ?>"></div>
|
|
</div></div>
|
|
<button class="btn btn-g" type="submit">SAVE PLATFORM SERVICES</button>
|
|
</form>
|
|
|
|
<div class="panel" style="margin-top:20px"><div class="panel-head"><span class="panel-title">// PLANS, PRICES & ENTITLEMENTS</span></div>
|
|
<div class="panel-body"><form method="post">
|
|
<input type="hidden" name="csrf" value="<?= csrfToken() ?>"><input type="hidden" name="action" value="save_plans">
|
|
<?php foreach ($plans as $plan): ?>
|
|
<div class="danger-zone" style="border-color:var(--bd2);margin:0 0 14px">
|
|
<div class="form-row3">
|
|
<div class="field"><label>Name / <?= esc($plan['slug']) ?></label><input name="plans[<?= $plan['id'] ?>][name]" value="<?= esc($plan['name']) ?>"></div>
|
|
<div class="field"><label>Price Cents</label><input type="number" min="0" name="plans[<?= $plan['id'] ?>][price_cents]" value="<?= $plan['price_cents'] ?>"></div>
|
|
<div class="field"><label>Stripe Price ID</label><input name="plans[<?= $plan['id'] ?>][stripe_price_id]" value="<?= esc($plan['stripe_price_id']) ?>" placeholder="price_..."></div>
|
|
</div>
|
|
<div class="field"><label>Description</label><input name="plans[<?= $plan['id'] ?>][description]" value="<?= esc($plan['description']) ?>"></div>
|
|
<div class="form-row3">
|
|
<div class="field"><label>Currency</label><input maxlength="3" name="plans[<?= $plan['id'] ?>][currency]" value="<?= esc($plan['currency']) ?>"></div>
|
|
<div class="field"><label>Interval</label><select name="plans[<?= $plan['id'] ?>][billing_interval]">
|
|
<?php foreach (['day','week','month','year'] as $interval): ?><option <?= $plan['billing_interval']===$interval?'selected':'' ?>><?= $interval ?></option><?php endforeach; ?>
|
|
</select></div>
|
|
<div class="field"><label>Sort Order</label><input type="number" name="plans[<?= $plan['id'] ?>][sort_order]" value="<?= (int)$plan['sort_order'] ?>"></div>
|
|
</div>
|
|
<label class="cb-row"><input type="checkbox" name="plans[<?= $plan['id'] ?>][active]" <?= $plan['active'] ? 'checked' : '' ?>><span>Plan active</span></label>
|
|
<div class="form-row3">
|
|
<?php foreach ($featureKeys as $key): ?>
|
|
<?php if ($key === 'text_archive_days'): ?>
|
|
<div class="field"><label><?= esc(adminFeatureLabel($key)) ?></label><input type="number"
|
|
min="30"
|
|
name="plans[<?= $plan['id'] ?>][features][<?= $key ?>]" value="<?= (int)($plan['features'][$key] ?? 0) ?>"></div>
|
|
<?php else: ?>
|
|
<label class="cb-row"><input type="checkbox" name="plans[<?= $plan['id'] ?>][features][<?= $key ?>]"
|
|
<?= !empty($plan['features'][$key]) ? 'checked' : '' ?>><span><?= esc(adminFeatureLabel($key)) ?></span></label>
|
|
<?php endif; ?>
|
|
<?php endforeach; ?>
|
|
</div>
|
|
</div>
|
|
<?php endforeach; ?>
|
|
<button class="btn btn-g" type="submit">SAVE ALL PLANS</button>
|
|
</form></div>
|
|
</div>
|
|
|
|
<div class="panel"><div class="panel-head"><span class="panel-title">// CREATE TIER</span></div><div class="panel-body">
|
|
<form method="post"><input type="hidden" name="csrf" value="<?= csrfToken() ?>"><input type="hidden" name="action" value="create_plan">
|
|
<div class="form-row3"><div class="field"><label>Slug</label><input name="slug" placeholder="business"></div>
|
|
<div class="field"><label>Name</label><input name="name" placeholder="Business"></div>
|
|
<div class="field"><label>Description</label><input name="description"></div></div>
|
|
<button class="btn btn-b" type="submit">CREATE TIER</button>
|
|
</form></div></div>
|
|
|
|
<div class="panel"><div class="panel-head"><span class="panel-title">// SUBSCRIBER TRACKING</span></div><div class="panel-body np">
|
|
<div class="tbl-wrap"><table><thead><tr><th>User</th><th>Email</th><th>Plan</th><th>Status</th><th>Period End</th><th>Stripe</th></tr></thead><tbody>
|
|
<?php foreach ($subscribers as $subscriber): ?><tr>
|
|
<td><?= esc($subscriber['username']) ?></td>
|
|
<td class="dim"><?= esc($subscriber['email'] ?: 'not set') ?> <?= $subscriber['email_verified_at'] ? '<span class="badge badge-g">VERIFIED</span>' : '' ?></td>
|
|
<td><?= esc($subscriber['plan_name'] ?: 'Free') ?><?= $subscriber['manual_plan_id'] ? ' <span class="badge badge-o">ADMIN</span>' : '' ?></td>
|
|
<td><span class="badge <?= in_array($subscriber['subscription_status'], ['active','trialing'], true) ? 'badge-g' : 'badge-o' ?>"><?= esc(strtoupper($subscriber['subscription_status'])) ?></span></td>
|
|
<td class="dim"><?= $subscriber['subscription_period_end'] ? esc(fmtTime((int)$subscriber['subscription_period_end'])) : '-' ?><?= $subscriber['cancel_at_period_end'] ? ' / CANCELING' : '' ?></td>
|
|
<td class="dim"><?= esc($subscriber['stripe_customer_id'] ?: '-') ?></td>
|
|
</tr><?php endforeach; ?></tbody></table></div>
|
|
</div></div>
|
|
|
|
<div class="panel"><div class="panel-head"><span class="panel-title">// 30-DAY EVENT TOTALS</span></div><div class="panel-body">
|
|
<div class="btn-group"><?php foreach ($events as $event): ?><span class="badge badge-b"><?= esc($event['event_type']) ?>: <?= (int)$event['total'] ?></span><?php endforeach; ?></div>
|
|
</div></div>
|
|
|
|
<form method="post"><input type="hidden" name="csrf" value="<?= csrfToken() ?>"><input type="hidden" name="action" value="run_mysql_mirror">
|
|
<button class="btn btn-v" type="submit">RUN MYSQL MIRROR NOW</button></form>
|
|
<?php
|
|
}
|