Files
chat/lib/admin_platform.php
T
Ty Clifford 2881a3b338 - Groups are now globally configurable under Admin > Plans & Platform > Group Availability.
When disabled:

Group navigation and channel selector are hidden.
Group API operations are blocked.
Active sessions update automatically.
Existing groups and messages remain preserved.
Static and browser tests passed with no console errors.
2026-06-08 16:21:33 -04:00

249 lines
18 KiB
PHP

<?php
function handleAdminPlatformAction(string $action): ?string {
if ($action === 'save_platform_services') {
setConfigValues([
'subscriptions.enabled' => !empty($_POST['subscriptions_enabled']),
'groups.enabled' => !empty($_POST['groups_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, 'group_member_limit', json_encode(max(2, min(100, (int)($plan['features']['group_member_limit'] ?? 2))))]);
$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,
'group_member_limit' => 2, '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(),
'groups' => (int)$db->query("SELECT COUNT(*) FROM chat_groups")->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', 'group_member_limit',
'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 class="stat-card orange"><div class="stat-num"><?= $stats['groups'] ?></div><div class="stat-label">Private Groups</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">// GROUP AVAILABILITY</span></div>
<div class="panel-body"><label class="cb-row"><input type="checkbox" name="groups_enabled" <?= groupsEnabled() ? 'checked' : '' ?>>
<span>Enable private groups. When off, group navigation and channels are hidden while existing group data is preserved.</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 (in_array($key, ['group_member_limit','text_archive_days'], true)): ?>
<div class="field"><label><?= esc(adminFeatureLabel($key)) ?></label><input type="number"
min="<?= $key==='text_archive_days' ? 30 : 2 ?>"
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
}