diff --git a/backend/Admin/marketing/trigger_campaign.php b/backend/Admin/marketing/trigger_campaign.php index 68501aef..7f779d28 100644 --- a/backend/Admin/marketing/trigger_campaign.php +++ b/backend/Admin/marketing/trigger_campaign.php @@ -109,6 +109,22 @@ try { $dispatchedPassengers = []; $fcmErrors = []; + // 5.5 وضع المعاينة: يُرجع ما ستفعله الحملة (النص، الكود، حجم الجمهور) + // دون إنشاء كود ترويجي ودون إرسال أي إشعار. الحملة تُنشئ خصماً حقيقياً + // وتصل كل ركاب الدولة، فوجود معاينة قبل الإطلاق ضروري. + if (filterRequest('dry_run') === '1') { + jsonSuccess([ + 'dry_run' => true, + 'campaign_created' => false, + 'promo_code' => $promoCode, + 'discount_percent' => $discountVal, + 'region' => $regionName, + 'country_code' => strtoupper($countryCode), + 'audience_size' => count($targets), + 'ai_analysis' => $aiCampaign, + ], 'Preview only — no promo code was created and no notification was sent.'); + } + // 6. Save broadcast promo for this campaign (Option 1 - promos table adjustment) $sqlPromo = "INSERT INTO promos (promo_code, amount, description, passengerID, source, validity_start_date, validity_end_date) diff --git a/backend/serviceapp/updatePackages.php b/backend/serviceapp/updatePackages.php index 2aeaf61d..a3c307c9 100644 --- a/backend/serviceapp/updatePackages.php +++ b/backend/serviceapp/updatePackages.php @@ -1,26 +1,53 @@ 'failure', + 'message' => 'Forbidden. Admin access required.', + ], JSON_UNESCAPED_UNICODE); + exit; +} + +$id = filterRequest("id"); $version = filterRequest("version"); +if (empty($id) || empty($version)) { + jsonError("Both id and version are required.", 400); +} +// شكل الإصدار: أرقام ونقاط فقط (مثل 1.2.3) — يمنع أي محتوى آخر. +if (!preg_match('/^\d+(\.\d+){0,3}$/', $version)) { + jsonError("Invalid version format. Use digits separated by dots, e.g. 1.4.2", 400); +} -$sql = "UPDATE `packageInfo` SET `version` ='$version' WHERE `id` = '$id'"; +/** + * سابقاً كان الاستعلام يُبنى بدمج القيم مباشرة: + * "UPDATE packageInfo SET version ='$version' WHERE id = '$id'" + * وهو حقن SQL مباشر — أي مستخدم يملك توكناً صالحاً كان يستطيع تنفيذ ما يشاء + * على قاعدة البيانات عبر حقل الإصدار. الآن القيم مرتبطة كمعاملات. + */ +$stmt = $con->prepare("UPDATE `packageInfo` SET `version` = :version WHERE `id` = :id"); +$stmt->execute([ + ':version' => $version, + ':id' => $id, +]); -// Prepare and execute the statement -$stmt = $con->prepare($sql); -$stmt->execute(); -error_log("Updating package: ID = $sql, Version = $version"); +if (function_exists('logAudit')) { + try { + logAudit($con, (string) ($user_id ?? 'unknown'), 'تحديث إصدار التطبيق', 'packageInfo', $id, [ + 'version' => $version, + ]); + } catch (Throwable $e) { + error_log("[updatePackages] audit failed: " . $e->getMessage()); + } +} - -// Debugging: Check if the query affected any rows if ($stmt->rowCount() > 0) { - // If rows were affected, print success - echo json_encode(['status' => 'success', 'message' => "Package version updated successfully for ID $id"]); + jsonSuccess(['id' => $id, 'version' => $version], "Package version updated successfully for ID $id"); } else { - // If no rows were affected, print failure and debug the query - echo json_encode(['status' => 'failure', 'message' => "Failed to update package version. No rows affected. ID: $id, Version: $version"]); + jsonError("No package row was updated — check that ID $id exists and the version differs.", 404); } -?> \ No newline at end of file diff --git a/dashboard/siro-admin/index.html b/dashboard/siro-admin/index.html index 90878fa8..df13f403 100644 --- a/dashboard/siro-admin/index.html +++ b/dashboard/siro-admin/index.html @@ -12,7 +12,10 @@ - + + @@ -601,6 +604,6 @@ - + diff --git a/dashboard/siro-admin/js/app.js b/dashboard/siro-admin/js/app.js index 73b0b632..6fcf9928 100644 --- a/dashboard/siro-admin/js/app.js +++ b/dashboard/siro-admin/js/app.js @@ -8,6 +8,11 @@ (() => { 'use strict'; + // Bump together with the ?v= query in index.html. Shown in the UI and in the + // diagnostics report so "the deploy did nothing" can be answered with a fact + // rather than a guess about caching. + const BUILD = '2026-07-25-2'; + const SESSION_KEY = 'siro_admin_user'; const FP_KEY = 'siro_web_fp'; const API_BASE_KEY = 'siro_api_base'; @@ -785,6 +790,7 @@ ['Token expires', expiresAt.toLocaleString()], ['Device fingerprint', deviceFingerprint.slice(0, 24) + '…'], ['API endpoint', location.origin + API_BASE], + ['Console build', BUILD], ]; el.sessionInfo.innerHTML = rows.map(([k, v]) => `
${k}${esc(String(v))}
`).join(''); @@ -913,6 +919,18 @@ subtitle: 'Draft routes submitted by organisations, awaiting a decision', custom: renderRouteApprovals, }, + { + id: 'campaigns', superOnly: true, group: 'Growth & Pricing', icon: 'ph-rocket-launch', + title: 'Campaign Launcher', + subtitle: 'Generate an AI pricing campaign, preview it, then dispatch', + custom: renderCampaigns, + }, + { + id: 'appVersion', superOnly: true, group: 'Administration', icon: 'ph-device-mobile', + title: 'App Versions', + subtitle: 'The version each Siro app checks itself against', + custom: renderAppVersions, + }, { id: 'broadcast', superOnly: true, group: 'Administration', icon: 'ph-megaphone-simple', title: 'Broadcast Notification', @@ -1058,6 +1076,209 @@ }); } + // ── Campaign launcher ──────────────────────────────────────────────────── + // trigger_campaign.php asks Gemini for a campaign, writes a promo code valid + // for seven days, and pushes it to every passenger in the country. The + // preview (dry_run=1) runs the same analysis and stops before both. + function renderCampaigns(host) { + host.innerHTML = ` +
+ + Launching creates a real discount code and notifies every passenger in the selected country. + The promo stays valid for seven days. Always preview first. +
+ +
+

Campaign parameters

+
+ + + +
+
+ + + Preview first to enable launching. +
+
+ +
+
No analysis run yet.
+
+ +
Loading campaign history…
`; + + $('cmpPreview').addEventListener('click', () => runCampaign(true)); + $('cmpLaunch').addEventListener('click', () => runCampaign(false)); + loadCampaignLog(); + } + + // A launch is only allowed for parameters that were previewed, so an edit + // after previewing disarms the button again. + let previewedCampaign = null; + + function campaignParams() { + return { + country_code: $('cmpCountry').value, + region_name: $('cmpRegion').value.trim(), + siro_base_price: $('cmpBasePrice').value.trim(), + }; + } + + async function runCampaign(isPreview) { + const params = campaignParams(); + const signature = JSON.stringify(params); + + if (!isPreview) { + if (signature !== previewedCampaign) { + toast('Parameters changed since the preview — preview again before launching.', 'warning'); + $('cmpLaunch').disabled = true; + return; + } + if (!confirm( + `Launch this campaign in ${params.country_code}?\n\n` + + 'It creates a discount code valid for 7 days and pushes a notification to every passenger there.\n\n' + + 'This cannot be undone.' + )) return; + } + + const btn = isPreview ? $('cmpPreview') : $('cmpLaunch'); + busy(btn, true, isPreview ? 'Analysing…' : 'Launching…'); + $('cmpResult').innerHTML = '
Running market analysis…
'; + + try { + const payload = await api('/Admin/marketing/trigger_campaign.php', { + params: isPreview ? { ...params, dry_run: '1' } : params, + }); + + $('cmpResult').innerHTML = `

${isPreview ? 'Preview' : 'Launch result'}

`; + renderPayload($('cmpResult').querySelector('.panel-body'), payload); + + if (isPreview) { + const opportunity = payload?.ai_analysis?.opportunity_detected ?? payload?.campaign_created; + previewedCampaign = signature; + $('cmpLaunch').disabled = false; + $('cmpStatus').textContent = opportunity === false + ? 'The AI found no opportunity — launching would still send.' + : `Previewed ${params.country_code}. Launch is now enabled.`; + } else { + previewedCampaign = null; + $('cmpLaunch').disabled = true; + $('cmpStatus').textContent = `Launched at ${new Date().toLocaleTimeString()}`; + toast('Campaign dispatched.', 'success'); + loadCampaignLog(); + } + } catch (err) { + if (!handleApiError(err, 'campaign')) { + $('cmpResult').innerHTML = `
${esc(err.message)}
`; + toast(err.message, 'danger'); + } + } finally { + busy(btn, false, isPreview ? 'Preview' : 'Launch campaign'); + // busy() clears `disabled`, so re-apply the arming rule afterwards: + // launching stays locked until the current parameters are previewed. + $('cmpLaunch').disabled = previewedCampaign !== JSON.stringify(campaignParams()); + } + } + + async function loadCampaignLog() { + const panel = $('cmpLog'); + try { + const payload = await api('/Admin/marketing/get_campaigns_log.php'); + panel.innerHTML = '

Campaign history

'; + renderPayload(panel.querySelector('.panel-body'), payload); + } catch (err) { + if (handleApiError(err, 'campaign-log')) return; + panel.innerHTML = `

Campaign history

${esc(err.message)}
`; + } + } + + // ── App versions ───────────────────────────────────────────────────────── + async function renderAppVersions(host) { + host.innerHTML = '
Loading package versions…
'; + + let packages = []; + try { + packages = normaliseRows(await api('/serviceapp/getPackages.php')); + } catch (err) { + if (handleApiError(err, 'packages')) return; + host.innerHTML = `
${esc(err.message)}
`; + return; + } + + if (!packages.length) { + host.innerHTML = '
No package rows configured.
'; + return; + } + + host.innerHTML = ` +
+ + Each app compares its own build against this number on launch. Raising it can force every + user of that app to update before they can continue. +
+ ${packages.map((pkg, index) => ` +
+
+

+ ${esc(pkg.name || pkg.packageName || pkg.app_name || `Package #${pkg.id}`)} + row #${esc(pkg.id)} +

+ +
+
+ +
+
`).join('')}`; + + host.querySelectorAll('[data-pkg-save]').forEach((btn) => + btn.addEventListener('click', () => saveVersion(packages[Number(btn.dataset.pkgSave)], Number(btn.dataset.pkgSave), host))); + } + + async function saveVersion(pkg, index, host) { + const input = host.querySelector(`[data-pkg-version="${index}"]`); + const version = input.value.trim(); + + if (version === String(pkg.version ?? '')) { + toast('Version unchanged.', 'info'); + return; + } + // Mirrors the server-side check so a typo is caught before the request. + if (!/^\d+(\.\d+){0,3}$/.test(version)) { + toast('Use digits separated by dots, e.g. 1.4.2', 'warning'); + return; + } + if (!confirm( + `Set ${pkg.name || `package #${pkg.id}`} to version ${version} (was ${pkg.version ?? '—'})?\n\n` + + 'Users on an older build may be prompted or forced to update.' + )) return; + + try { + await api('/serviceapp/updatePackages.php', { params: { id: pkg.id, version } }); + toast(`Version set to ${version}.`, 'success'); + renderAppVersions(host); + } catch (err) { + if (!handleApiError(err, 'package-save')) toast(err.message, 'danger'); + } + } + // ── Driver document review ─────────────────────────────────────────────── // The list is paged server-side (limit/offset). Activation posts // status=active to Admin/driver/updateDriverFromAdmin.php, exactly as the @@ -1846,6 +2067,7 @@ const lines = [ `Siro Admin diagnostics — ${new Date().toISOString()}`, + `Console build: ${BUILD}`, `Page origin : ${location.origin}`, `API base : ${API_BASE}`, `Fingerprint : ${deviceFingerprint.slice(0, 20)}…`,