feat: add TeleCartPulse telemetry system and ETL endpoints
- Add TeleCartPulse service for event tracking and analytics - Implement PayloadSigner for secure payload signing/verification - Add StartParamSerializer for campaign parameter handling - Create TeleCartPulseServiceProvider for dependency injection - Add PulseEvents constants and exception classes - Add TelemetryHandler for ingesting client-side events - Implement /ingest endpoint for receiving webapp events - Support WEBAPP_OPEN event tracking with campaign metadata - Add ETLHandler for customer data export - Implement /customers endpoint for ETL processes - Add /customers/meta endpoint for pagination metadata - Support filtering by updated_at timestamp - Include customer metrics: orders count, total spent, etc. - Add InvalidApiTokenException for API key validation - Update Request class to support API key extraction - Add Utils helper methods for domain extraction - Integrate telemetry in frontend SPA (webapp open event) - Add TeleCartPulseView in admin panel for API key configuration - Update routes to include new telemetry and ETL endpoints
This commit is contained in:
@@ -38,6 +38,10 @@
|
|||||||
<RouterLink :to="{name: 'customers'}">Telegram Покупатели</RouterLink>
|
<RouterLink :to="{name: 'customers'}">Telegram Покупатели</RouterLink>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<li :class="{active: route.name === 'pulse'}">
|
||||||
|
<RouterLink :to="{name: 'pulse'}">TeleCart Pulse</RouterLink>
|
||||||
|
</li>
|
||||||
|
|
||||||
<li :class="{active: route.name === 'logs'}">
|
<li :class="{active: route.name === 'logs'}">
|
||||||
<RouterLink :to="{name: 'logs'}">Журнал событий</RouterLink>
|
<RouterLink :to="{name: 'logs'}">Журнал событий</RouterLink>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import MainPageView from "@/views/MainPageView.vue";
|
|||||||
import LogsView from "@/views/LogsView.vue";
|
import LogsView from "@/views/LogsView.vue";
|
||||||
import FormBuilderView from "@/views/FormBuilderView.vue";
|
import FormBuilderView from "@/views/FormBuilderView.vue";
|
||||||
import CustomersView from "@/views/CustomersView.vue";
|
import CustomersView from "@/views/CustomersView.vue";
|
||||||
|
import TeleCartPulseView from "@/views/TeleCartPulseView.vue";
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createMemoryHistory(),
|
history: createMemoryHistory(),
|
||||||
@@ -20,6 +21,7 @@ const router = createRouter({
|
|||||||
{path: '/mainpage', name: 'mainpage', component: MainPageView},
|
{path: '/mainpage', name: 'mainpage', component: MainPageView},
|
||||||
{path: '/metrics', name: 'metrics', component: MetricsView},
|
{path: '/metrics', name: 'metrics', component: MetricsView},
|
||||||
{path: '/orders', name: 'orders', component: OrdersView},
|
{path: '/orders', name: 'orders', component: OrdersView},
|
||||||
|
{path: '/pulse', name: 'pulse', component: TeleCartPulseView},
|
||||||
{path: '/store', name: 'store', component: StoreView},
|
{path: '/store', name: 'store', component: StoreView},
|
||||||
{path: '/telegram', name: 'telegram', component: TelegramView},
|
{path: '/telegram', name: 'telegram', component: TelegramView},
|
||||||
{path: '/texts', name: 'texts', component: TextsView},
|
{path: '/texts', name: 'texts', component: TextsView},
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ export const useSettingsStore = defineStore('settings', {
|
|||||||
schema: [],
|
schema: [],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
pulse: {
|
||||||
|
api_key: '',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
15
frontend/admin/src/views/TeleCartPulseView.vue
Normal file
15
frontend/admin/src/views/TeleCartPulseView.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<template>
|
||||||
|
<ItemInput label="API ключ"
|
||||||
|
v-model="settings.items.pulse.api_key"
|
||||||
|
placeholder="AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE"
|
||||||
|
>
|
||||||
|
Используется для обмена информацией по кампаниям, рассылкам, сбору метрик.
|
||||||
|
</ItemInput>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import {useSettingsStore} from "@/stores/settings.js";
|
||||||
|
import ItemInput from "@/components/Settings/ItemInput.vue";
|
||||||
|
|
||||||
|
const settings = useSettingsStore();
|
||||||
|
</script>
|
||||||
3
frontend/spa/src/constants/tPulseEvents.js
Normal file
3
frontend/spa/src/constants/tPulseEvents.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export const TC_PULSE_EVENTS = {
|
||||||
|
WEBAPP_OPEN: 'WEBAPP_OPEN',
|
||||||
|
};
|
||||||
@@ -8,18 +8,18 @@ import {useSettingsStore} from "@/stores/SettingsStore.js";
|
|||||||
import ApplicationError from "@/ApplicationError.vue";
|
import ApplicationError from "@/ApplicationError.vue";
|
||||||
import AppMetaInitializer from "@/utils/AppMetaInitializer.ts";
|
import AppMetaInitializer from "@/utils/AppMetaInitializer.ts";
|
||||||
import {injectYaMetrika} from "@/utils/yaMetrika.js";
|
import {injectYaMetrika} from "@/utils/yaMetrika.js";
|
||||||
import {checkIsUserPrivacyConsented, saveTelegramCustomer} from "@/utils/ftch.js";
|
import {checkIsUserPrivacyConsented, ingest, saveTelegramCustomer} from "@/utils/ftch.js";
|
||||||
|
|
||||||
import {register} from 'swiper/element/bundle';
|
import {register} from 'swiper/element/bundle';
|
||||||
import 'swiper/element/bundle';
|
import 'swiper/element/bundle';
|
||||||
import 'swiper/css/bundle';
|
import 'swiper/css/bundle';
|
||||||
import AppLoading from "@/AppLoading.vue";
|
import AppLoading from "@/AppLoading.vue";
|
||||||
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
|
|
||||||
import {useBlocksStore} from "@/stores/BlocksStore.js";
|
import {useBlocksStore} from "@/stores/BlocksStore.js";
|
||||||
import {getCssVarOklchRgb} from "@/helpers.js";
|
import {getCssVarOklchRgb} from "@/helpers.js";
|
||||||
|
|
||||||
import {defaultConfig, plugin} from '@formkit/vue';
|
import {defaultConfig, plugin} from '@formkit/vue';
|
||||||
import config from './formkit.config.js';
|
import config from './formkit.config.js';
|
||||||
|
import {TC_PULSE_EVENTS} from "@/constants/tPulseEvents.js";
|
||||||
|
|
||||||
register();
|
register();
|
||||||
|
|
||||||
@@ -51,18 +51,22 @@ settings.load()
|
|||||||
throw new Error('App disabled (maintenance mode)');
|
throw new Error('App disabled (maintenance mode)');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(() => {
|
||||||
|
const webapp = window.Telegram.WebApp;
|
||||||
|
ingest({
|
||||||
|
event: TC_PULSE_EVENTS.WEBAPP_OPEN,
|
||||||
|
webapp,
|
||||||
|
})
|
||||||
|
.catch(err => console.error('Ingest failed:', err));
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
// Сохраняем данные Telegram-пользователя в базу данных
|
// Сохраняем данные Telegram-пользователя в базу данных
|
||||||
const userData = window.Telegram?.WebApp?.initDataUnsafe?.user;
|
const userData = window.Telegram?.WebApp?.initDataUnsafe?.user;
|
||||||
if (userData) {
|
if (userData) {
|
||||||
try {
|
|
||||||
console.debug('[Init] Saving Telegram customer data');
|
console.debug('[Init] Saving Telegram customer data');
|
||||||
await saveTelegramCustomer(userData);
|
saveTelegramCustomer(userData)
|
||||||
console.debug('[Init] Telegram customer data saved successfully');
|
.then(() => console.debug('[Init] Telegram customer data saved successfully'))
|
||||||
} catch (error) {
|
.catch(() => console.warn('[Init] Failed to save Telegram customer data:', error));
|
||||||
// Не прерываем загрузку приложения, если не удалось сохранить пользователя
|
|
||||||
console.warn('[Init] Failed to save Telegram customer data:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -82,11 +86,11 @@ settings.load()
|
|||||||
})();
|
})();
|
||||||
})
|
})
|
||||||
.then(() => blocks.processBlocks(settings.mainpage_blocks))
|
.then(() => blocks.processBlocks(settings.mainpage_blocks))
|
||||||
.then(async () => {
|
// .then(async () => {
|
||||||
console.debug('Load default filters for the main page');
|
// console.debug('Load default filters for the main page');
|
||||||
const filtersStore = useProductFiltersStore();
|
// const filtersStore = useProductFiltersStore();
|
||||||
filtersStore.applied = await filtersStore.fetchFiltersForMainPage();
|
// filtersStore.applied = await filtersStore.fetchFiltersForMainPage();
|
||||||
})
|
// })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.debug('[Init] Set theme attributes');
|
console.debug('[Init] Set theme attributes');
|
||||||
document.documentElement.setAttribute('data-theme', settings.theme[window.Telegram.WebApp.colorScheme]);
|
document.documentElement.setAttribute('data-theme', settings.theme[window.Telegram.WebApp.colorScheme]);
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
|
PULSE_API_HOST=http://host.docker.internal:8086/api/
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use Openguru\OpenCartFramework\Cache\CacheServiceProvider;
|
|||||||
use Openguru\OpenCartFramework\QueryBuilder\QueryBuilderServiceProvider;
|
use Openguru\OpenCartFramework\QueryBuilder\QueryBuilderServiceProvider;
|
||||||
use Openguru\OpenCartFramework\Router\RouteServiceProvider;
|
use Openguru\OpenCartFramework\Router\RouteServiceProvider;
|
||||||
use Openguru\OpenCartFramework\Support\Arr;
|
use Openguru\OpenCartFramework\Support\Arr;
|
||||||
|
use Openguru\OpenCartFramework\TeleCartPulse\TeleCartPulseServiceProvider;
|
||||||
use Openguru\OpenCartFramework\Telegram\TelegramServiceProvider;
|
use Openguru\OpenCartFramework\Telegram\TelegramServiceProvider;
|
||||||
|
|
||||||
class ApplicationFactory
|
class ApplicationFactory
|
||||||
@@ -31,6 +32,7 @@ class ApplicationFactory
|
|||||||
AppServiceProvider::class,
|
AppServiceProvider::class,
|
||||||
CacheServiceProvider::class,
|
CacheServiceProvider::class,
|
||||||
TelegramServiceProvider::class,
|
TelegramServiceProvider::class,
|
||||||
|
TeleCartPulseServiceProvider::class,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ class SettingsHandler
|
|||||||
'texts',
|
'texts',
|
||||||
'sliders',
|
'sliders',
|
||||||
'mainpage_blocks',
|
'mainpage_blocks',
|
||||||
|
'pulse',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$data['forms'] = [];
|
$data['forms'] = [];
|
||||||
@@ -107,6 +108,7 @@ class SettingsHandler
|
|||||||
'texts',
|
'texts',
|
||||||
'sliders',
|
'sliders',
|
||||||
'mainpage_blocks',
|
'mainpage_blocks',
|
||||||
|
'pulse',
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Openguru\OpenCartFramework;
|
|||||||
use ErrorException;
|
use ErrorException;
|
||||||
use Openguru\OpenCartFramework\Contracts\ExceptionHandlerInterface;
|
use Openguru\OpenCartFramework\Contracts\ExceptionHandlerInterface;
|
||||||
use Openguru\OpenCartFramework\Exceptions\ActionNotFoundException;
|
use Openguru\OpenCartFramework\Exceptions\ActionNotFoundException;
|
||||||
|
use Openguru\OpenCartFramework\Exceptions\InvalidApiTokenException;
|
||||||
use Openguru\OpenCartFramework\Exceptions\NonLoggableExceptionInterface;
|
use Openguru\OpenCartFramework\Exceptions\NonLoggableExceptionInterface;
|
||||||
use Openguru\OpenCartFramework\Http\JsonResponse;
|
use Openguru\OpenCartFramework\Http\JsonResponse;
|
||||||
use Openguru\OpenCartFramework\Http\Response;
|
use Openguru\OpenCartFramework\Http\Response;
|
||||||
@@ -71,6 +72,13 @@ class ErrorHandler
|
|||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($exception instanceof InvalidApiTokenException) {
|
||||||
|
(new JsonResponse([
|
||||||
|
'message' => $exception->getMessage(),
|
||||||
|
], $exception->getCode()))->send();
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
if (PHP_SAPI === 'cli') {
|
if (PHP_SAPI === 'cli') {
|
||||||
echo $exception->getMessage() . PHP_EOL;
|
echo $exception->getMessage() . PHP_EOL;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Openguru\OpenCartFramework\Exceptions;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class InvalidApiTokenException extends Exception
|
||||||
|
{
|
||||||
|
public function __construct($message = "Token is invalid", $code = 401, Throwable $previous = null)
|
||||||
|
{
|
||||||
|
parent::__construct($message, $code, $previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,7 +41,7 @@ final class Request
|
|||||||
public function getContent(): string
|
public function getContent(): string
|
||||||
{
|
{
|
||||||
if ($this->content === null || $this->content === '') {
|
if ($this->content === null || $this->content === '') {
|
||||||
$this->content = (string) file_get_contents('php://input');
|
$this->content = (string)file_get_contents('php://input');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->content;
|
return $this->content;
|
||||||
@@ -115,4 +115,14 @@ final class Request
|
|||||||
|
|
||||||
return $this->json($key) !== null;
|
return $this->json($key) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getApiKey(): ?string
|
||||||
|
{
|
||||||
|
$header =
|
||||||
|
$_SERVER['HTTP_X_API_KEY']
|
||||||
|
?? $this->header('X-API-KEY')
|
||||||
|
?? null;
|
||||||
|
|
||||||
|
return $header ? trim($header) : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,4 +71,31 @@ class Utils
|
|||||||
{
|
{
|
||||||
return $value === true ? 1 : 0;
|
return $value === true ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function getCurrentDomain(): string
|
||||||
|
{
|
||||||
|
$scheme = 'http';
|
||||||
|
if (
|
||||||
|
(! empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ||
|
||||||
|
(! empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https')
|
||||||
|
) {
|
||||||
|
$scheme = 'https';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Определяем хост
|
||||||
|
$host = $_SERVER['HTTP_X_FORWARDED_HOST']
|
||||||
|
?? $_SERVER['HTTP_HOST']
|
||||||
|
?? $_SERVER['SERVER_NAME']
|
||||||
|
?? 'localhost';
|
||||||
|
|
||||||
|
// Порт
|
||||||
|
$port = $_SERVER['SERVER_PORT'] ?? null;
|
||||||
|
$defaultPort = ($scheme === 'https') ? 443 : 80;
|
||||||
|
|
||||||
|
if ($port && $port != $defaultPort && strpos($host, ':') === false) {
|
||||||
|
$host .= ':' . $port;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $scheme . '://' . $host;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Openguru\OpenCartFramework\TeleCartPulse;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class PayloadSignException extends Exception
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Openguru\OpenCartFramework\TeleCartPulse;
|
||||||
|
|
||||||
|
use JsonException;
|
||||||
|
|
||||||
|
class PayloadSigner
|
||||||
|
{
|
||||||
|
private string $secret;
|
||||||
|
|
||||||
|
public function __construct(string $secret)
|
||||||
|
{
|
||||||
|
$this->secret = $secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws PayloadSignException
|
||||||
|
*/
|
||||||
|
public function sign(array $payload): string
|
||||||
|
{
|
||||||
|
$encoded = $this->encodeJson($payload);
|
||||||
|
|
||||||
|
return hash_hmac('sha256', $encoded, $this->secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws PayloadSignException
|
||||||
|
*/
|
||||||
|
public function verify(string $signature, array $payload): bool
|
||||||
|
{
|
||||||
|
$encoded = $this->encodeJson($payload);
|
||||||
|
$expected = hash_hmac('sha256', $encoded, $this->secret);
|
||||||
|
|
||||||
|
return hash_equals($signature, $expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function encodeJson(array $payload): string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
} catch (JsonException $e) {
|
||||||
|
throw new PayloadSignException('Could not encode JSON: ' . $e->getMessage(), 0, $e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Openguru\OpenCartFramework\TeleCartPulse;
|
||||||
|
|
||||||
|
final class PulseEvents
|
||||||
|
{
|
||||||
|
public const WEBAPP_OPEN = 'WEBAPP_OPEN';
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Openguru\OpenCartFramework\TeleCartPulse;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class PulseIngestException extends Exception
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Openguru\OpenCartFramework\TeleCartPulse;
|
||||||
|
|
||||||
|
class StartParamSerializer
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Сериализовать массив параметров в строку для передачи в startapp
|
||||||
|
*
|
||||||
|
* Используется base64 кодирование JSON для безопасной передачи данных в URL
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $parameters Массив параметров для передачи
|
||||||
|
* @return string Сериализованная строка параметров
|
||||||
|
* @throws \InvalidArgumentException Если не удалось закодировать параметры
|
||||||
|
*/
|
||||||
|
public static function serialize(array $parameters): string
|
||||||
|
{
|
||||||
|
if (empty($parameters)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = json_encode($parameters, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
|
if ($json === false) {
|
||||||
|
throw new \InvalidArgumentException('Failed to encode parameters to JSON: ' . json_last_error_msg());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Используем base64 для безопасной передачи в URL
|
||||||
|
$encoded = base64_encode($json);
|
||||||
|
|
||||||
|
// Заменяем символы, которые могут вызвать проблемы в URL
|
||||||
|
// + на -, / на _, убираем = в конце (padding)
|
||||||
|
return rtrim(strtr($encoded, '+/', '-_'), '=');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Десериализовать строку параметров обратно в массив
|
||||||
|
*
|
||||||
|
* @param string $serialized Сериализованная строка параметров
|
||||||
|
* @return array<string, mixed> Массив параметров
|
||||||
|
* @throws \InvalidArgumentException Если строка не может быть десериализована
|
||||||
|
*/
|
||||||
|
public static function deserialize(string $serialized): array
|
||||||
|
{
|
||||||
|
if (empty($serialized)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Восстанавливаем base64 символы
|
||||||
|
$encoded = strtr($serialized, '-_', '+/');
|
||||||
|
|
||||||
|
// Добавляем padding если нужно
|
||||||
|
$padding = strlen($encoded) % 4;
|
||||||
|
if ($padding !== 0) {
|
||||||
|
$encoded .= str_repeat('=', 4 - $padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = base64_decode($encoded, true);
|
||||||
|
|
||||||
|
if ($json === false) {
|
||||||
|
throw new \InvalidArgumentException('Failed to decode base64 string');
|
||||||
|
}
|
||||||
|
|
||||||
|
$parameters = json_decode($json, true);
|
||||||
|
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||||
|
throw new \InvalidArgumentException('Failed to decode JSON: ' . json_last_error_msg());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_array($parameters)) {
|
||||||
|
throw new \InvalidArgumentException('Decoded value is not an array');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Openguru\OpenCartFramework\TeleCartPulse;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use GuzzleHttp\Client;
|
||||||
|
use GuzzleHttp\Exception\ClientException;
|
||||||
|
use GuzzleHttp\Exception\GuzzleException;
|
||||||
|
use Openguru\OpenCartFramework\Support\Arr;
|
||||||
|
use Openguru\OpenCartFramework\Support\Utils;
|
||||||
|
use Openguru\OpenCartFramework\Telegram\TelegramInitDataDecoder;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class TeleCartPulseService
|
||||||
|
{
|
||||||
|
private TelegramInitDataDecoder $initDataDecoder;
|
||||||
|
private PayloadSigner $payloadSigner;
|
||||||
|
private string $apiKey;
|
||||||
|
|
||||||
|
public function __construct(TelegramInitDataDecoder $initDataDecoder, PayloadSigner $payloadSigner, string $apiKey)
|
||||||
|
{
|
||||||
|
$this->initDataDecoder = $initDataDecoder;
|
||||||
|
$this->payloadSigner = $payloadSigner;
|
||||||
|
$this->apiKey = $apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws PulseIngestException
|
||||||
|
*/
|
||||||
|
public function handleIngest(array $data): void
|
||||||
|
{
|
||||||
|
if (! $this->apiKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$initData = Arr::get($data, 'webapp.initData');
|
||||||
|
if (! $initData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$event = Arr::get($data, 'event');
|
||||||
|
|
||||||
|
if (! $event) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$decoded = $this->initDataDecoder->parseInitDataStringToArray($initData);
|
||||||
|
$startParam = Arr::get($decoded, 'start_param');
|
||||||
|
$deserialized = StartParamSerializer::deserialize($startParam);
|
||||||
|
|
||||||
|
if ($event === PulseEvents::WEBAPP_OPEN) {
|
||||||
|
$this->handleWebAppInit($data, $deserialized);
|
||||||
|
}
|
||||||
|
} catch (ClientException $exception) {
|
||||||
|
$contents = (string)$exception->getResponse()->getBody();
|
||||||
|
$decoded = json_decode($contents, true);
|
||||||
|
throw new PulseIngestException('TeleCart Pulse API error: ' . $decoded['error'] ?? '', 0, $exception);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
throw new PulseIngestException('Could not handle ingest: ' . $exception->getMessage(), 0, $exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws PayloadSignException
|
||||||
|
* @throws GuzzleException
|
||||||
|
*/
|
||||||
|
private function handleWebAppInit(array $data, array $deserialized): void
|
||||||
|
{
|
||||||
|
// Campaign Event
|
||||||
|
if (isset($deserialized['campaign_id'], $deserialized['tracking_id'])) {
|
||||||
|
$payload = [
|
||||||
|
'event' => PulseEvents::WEBAPP_OPEN,
|
||||||
|
'campaign_id' => $deserialized['campaign_id'],
|
||||||
|
'tracking_id' => $deserialized['tracking_id'],
|
||||||
|
'meta' => [
|
||||||
|
'domain' => Utils::getCurrentDomain(),
|
||||||
|
'version' => Arr::get($data, 'webapp.version'),
|
||||||
|
'platform' => Arr::get($data, 'webapp.platform'),
|
||||||
|
],
|
||||||
|
'timestamp' => Carbon::now('UTC')->toJSON(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$dataToSend = [
|
||||||
|
'payload' => $payload,
|
||||||
|
'signature' => $this->payloadSigner->sign($payload),
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->pushEvent($dataToSend);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws GuzzleException
|
||||||
|
*/
|
||||||
|
private function pushEvent(array $json): void
|
||||||
|
{
|
||||||
|
$baseUri = rtrim(env('PULSE_API_HOST', 'http://localhost'), '/') . '/';
|
||||||
|
|
||||||
|
$client = new Client([
|
||||||
|
'base_uri' => $baseUri,
|
||||||
|
'timeout' => env('PULSE_TIMEOUT', 5.0),
|
||||||
|
'headers' => [
|
||||||
|
'Authorization' => 'Bearer ' . $this->apiKey,
|
||||||
|
'X-TELECART-VERSION' => '2.0.0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$client->post('events', compact('json'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Openguru\OpenCartFramework\TeleCartPulse;
|
||||||
|
|
||||||
|
use Openguru\OpenCartFramework\Container\Container;
|
||||||
|
use Openguru\OpenCartFramework\Container\ServiceProvider;
|
||||||
|
use Openguru\OpenCartFramework\Telegram\TelegramInitDataDecoder;
|
||||||
|
|
||||||
|
class TeleCartPulseServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
$this->container->singleton(PayloadSigner::class, function (Container $app) {
|
||||||
|
return new PayloadSigner(
|
||||||
|
$app->getConfigValue('pulse.api_key'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->container->singleton(TeleCartPulseService::class, function (Container $app) {
|
||||||
|
return new TeleCartPulseService(
|
||||||
|
$app->get(TelegramInitDataDecoder::class),
|
||||||
|
$app->get(PayloadSigner::class),
|
||||||
|
$app->getConfigValue('pulse.api_key'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
2
module/oc_telegram_shop/upload/oc_telegram_shop/framework/Telegram/TelegramInitDataDecoder.php
Normal file → Executable file
2
module/oc_telegram_shop/upload/oc_telegram_shop/framework/Telegram/TelegramInitDataDecoder.php
Normal file → Executable file
@@ -32,7 +32,7 @@ class TelegramInitDataDecoder
|
|||||||
/**
|
/**
|
||||||
* @throws JsonException
|
* @throws JsonException
|
||||||
*/
|
*/
|
||||||
private function parseInitDataStringToArray(string $initData): array
|
public function parseInitDataStringToArray(string $initData): array
|
||||||
{
|
{
|
||||||
parse_str($initData, $parsed);
|
parse_str($initData, $parsed);
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ class TelegramValidateInitDataMiddleware
|
|||||||
'manifest',
|
'manifest',
|
||||||
'webhook',
|
'webhook',
|
||||||
'health',
|
'health',
|
||||||
|
'etlCustomers',
|
||||||
|
'etlCustomersMeta',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function __construct(SignatureValidator $signatureValidator)
|
public function __construct(SignatureValidator $signatureValidator)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use Openguru\OpenCartFramework\Cache\CacheServiceProvider;
|
|||||||
use Openguru\OpenCartFramework\QueryBuilder\QueryBuilderServiceProvider;
|
use Openguru\OpenCartFramework\QueryBuilder\QueryBuilderServiceProvider;
|
||||||
use Openguru\OpenCartFramework\Router\RouteServiceProvider;
|
use Openguru\OpenCartFramework\Router\RouteServiceProvider;
|
||||||
use Openguru\OpenCartFramework\Support\Arr;
|
use Openguru\OpenCartFramework\Support\Arr;
|
||||||
|
use Openguru\OpenCartFramework\TeleCartPulse\TeleCartPulseServiceProvider;
|
||||||
use Openguru\OpenCartFramework\Telegram\TelegramServiceProvider;
|
use Openguru\OpenCartFramework\Telegram\TelegramServiceProvider;
|
||||||
use Openguru\OpenCartFramework\Telegram\TelegramValidateInitDataMiddleware;
|
use Openguru\OpenCartFramework\Telegram\TelegramValidateInitDataMiddleware;
|
||||||
use Openguru\OpenCartFramework\Validator\ValidatorServiceProvider;
|
use Openguru\OpenCartFramework\Validator\ValidatorServiceProvider;
|
||||||
@@ -30,6 +31,7 @@ class ApplicationFactory
|
|||||||
AppServiceProvider::class,
|
AppServiceProvider::class,
|
||||||
TelegramServiceProvider::class,
|
TelegramServiceProvider::class,
|
||||||
ValidatorServiceProvider::class,
|
ValidatorServiceProvider::class,
|
||||||
|
TeleCartPulseServiceProvider::class,
|
||||||
])
|
])
|
||||||
->withMiddlewares([
|
->withMiddlewares([
|
||||||
TelegramValidateInitDataMiddleware::class,
|
TelegramValidateInitDataMiddleware::class,
|
||||||
|
|||||||
168
module/oc_telegram_shop/upload/oc_telegram_shop/src/Handlers/ETLHandler.php
Executable file
168
module/oc_telegram_shop/upload/oc_telegram_shop/src/Handlers/ETLHandler.php
Executable file
@@ -0,0 +1,168 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Handlers;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Openguru\OpenCartFramework\Config\Settings;
|
||||||
|
use Openguru\OpenCartFramework\Exceptions\InvalidApiTokenException;
|
||||||
|
use Openguru\OpenCartFramework\Http\JsonResponse;
|
||||||
|
use Openguru\OpenCartFramework\Http\Request;
|
||||||
|
use Openguru\OpenCartFramework\QueryBuilder\Builder;
|
||||||
|
use Openguru\OpenCartFramework\QueryBuilder\RawExpression;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
class ETLHandler
|
||||||
|
{
|
||||||
|
private Builder $builder;
|
||||||
|
private Settings $settings;
|
||||||
|
private LoggerInterface $logger;
|
||||||
|
|
||||||
|
public function __construct(Builder $builder, Settings $settings, LoggerInterface $logger)
|
||||||
|
{
|
||||||
|
$this->builder = $builder;
|
||||||
|
$this->settings = $settings;
|
||||||
|
$this->logger = $logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getLastUpdatedAtSql(): string
|
||||||
|
{
|
||||||
|
return '
|
||||||
|
GREATEST(
|
||||||
|
COALESCE((
|
||||||
|
SELECT MAX(date_modified)
|
||||||
|
FROM oc_order as o
|
||||||
|
where o.customer_id = telecart_customers.oc_customer_id
|
||||||
|
), 0),
|
||||||
|
telecart_customers.updated_at
|
||||||
|
)
|
||||||
|
';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getCustomerQuery(?Carbon $updatedAt = null): Builder
|
||||||
|
{
|
||||||
|
$lastUpdatedAtSql = $this->getLastUpdatedAtSql();
|
||||||
|
|
||||||
|
return $this->builder->newQuery()
|
||||||
|
->from('telecart_customers')
|
||||||
|
->where('allows_write_to_pm', '=', 1)
|
||||||
|
->when(! empty($updatedAt), function (Builder $builder) use ($lastUpdatedAtSql, $updatedAt) {
|
||||||
|
$builder->where(new RawExpression($lastUpdatedAtSql), '>=', $updatedAt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws InvalidApiTokenException
|
||||||
|
*/
|
||||||
|
public function getCustomersMeta(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->validateApiKey($request);
|
||||||
|
|
||||||
|
$updatedAt = $request->get('updated_at');
|
||||||
|
if ($updatedAt) {
|
||||||
|
$updatedAt = Carbon::parse($updatedAt);
|
||||||
|
}
|
||||||
|
$query = $this->getCustomerQuery($updatedAt);
|
||||||
|
$total = $query->count();
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'data' => [
|
||||||
|
'total' => $total,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws InvalidApiTokenException
|
||||||
|
*/
|
||||||
|
public function customers(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->validateApiKey($request);
|
||||||
|
|
||||||
|
$this->logger->debug('Get customers for ETL');
|
||||||
|
|
||||||
|
$page = (int)$request->get('page', 1);
|
||||||
|
$perPage = (int)$request->get('perPage', 10000);
|
||||||
|
$successOrderStatusIds = '5,3';
|
||||||
|
$updatedAt = $request->get('updated_at');
|
||||||
|
if ($updatedAt) {
|
||||||
|
$updatedAt = Carbon::parse($updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastUpdatedAtSql = $this->getLastUpdatedAtSql();
|
||||||
|
$query = $this->getCustomerQuery($updatedAt);
|
||||||
|
|
||||||
|
$query->orderBy('telegram_user_id');
|
||||||
|
$query->forPage($page, $perPage);
|
||||||
|
|
||||||
|
$query
|
||||||
|
->select([
|
||||||
|
new RawExpression('md5(telegram_user_id) AS tracking_id'),
|
||||||
|
'telegram_user_id' => 'tg_user_id',
|
||||||
|
'telecart_customers.oc_customer_id',
|
||||||
|
'is_premium',
|
||||||
|
'last_seen_at',
|
||||||
|
'orders_count' => 'orders_count_total',
|
||||||
|
'created_at' => 'registered_at',
|
||||||
|
new RawExpression(
|
||||||
|
'(SELECT MIN(date_added) FROM oc_order WHERE oc_order.customer_id = telecart_customers.oc_customer_id) AS first_order_date'
|
||||||
|
),
|
||||||
|
new RawExpression(
|
||||||
|
'(SELECT MAX(date_added) FROM oc_order WHERE oc_order.customer_id = telecart_customers.oc_customer_id) AS last_order_date'
|
||||||
|
),
|
||||||
|
new RawExpression(
|
||||||
|
"COALESCE((
|
||||||
|
SELECT
|
||||||
|
SUM(total)
|
||||||
|
FROM
|
||||||
|
oc_order
|
||||||
|
WHERE
|
||||||
|
oc_order.customer_id = telecart_customers.oc_customer_id
|
||||||
|
AND oc_order.order_status_id IN ($successOrderStatusIds)
|
||||||
|
), 0) AS total_spent"
|
||||||
|
),
|
||||||
|
new RawExpression(
|
||||||
|
"COALESCE((
|
||||||
|
SELECT
|
||||||
|
COUNT(*)
|
||||||
|
FROM
|
||||||
|
oc_order
|
||||||
|
WHERE
|
||||||
|
oc_order.customer_id = telecart_customers.oc_customer_id
|
||||||
|
AND oc_order.order_status_id IN ($successOrderStatusIds)
|
||||||
|
), 0) AS orders_count_success"
|
||||||
|
),
|
||||||
|
new RawExpression("$lastUpdatedAtSql AS updated_at"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$items = $query->get();
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'data' => array_map(static function ($item) {
|
||||||
|
$item['is_premium'] = filter_var($item['is_premium'], FILTER_VALIDATE_BOOLEAN);
|
||||||
|
$item['orders_count_total'] = filter_var($item['orders_count_total'], FILTER_VALIDATE_INT);
|
||||||
|
$item['oc_customer_id'] = filter_var($item['oc_customer_id'], FILTER_VALIDATE_INT);
|
||||||
|
$item['tg_user_id'] = filter_var($item['tg_user_id'], FILTER_VALIDATE_INT);
|
||||||
|
$item['orders_count_success'] = filter_var($item['orders_count_success'], FILTER_VALIDATE_INT);
|
||||||
|
$item['total_spent'] = (float)$item['total_spent'];
|
||||||
|
|
||||||
|
return $item;
|
||||||
|
}, $items),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws InvalidApiTokenException
|
||||||
|
*/
|
||||||
|
private function validateApiKey(Request $request): void
|
||||||
|
{
|
||||||
|
$token = $request->getApiKey();
|
||||||
|
|
||||||
|
if (empty($token)) {
|
||||||
|
throw new InvalidApiTokenException('Invalid API Key.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strcmp($token, $this->settings->get('pulse.api_key')) !== 0) {
|
||||||
|
throw new InvalidApiTokenException('Invalid API Key');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Handlers;
|
||||||
|
|
||||||
|
use Openguru\OpenCartFramework\Http\JsonResponse;
|
||||||
|
use Openguru\OpenCartFramework\Http\Request;
|
||||||
|
use Openguru\OpenCartFramework\Http\Response;
|
||||||
|
use Openguru\OpenCartFramework\TeleCartPulse\PulseIngestException;
|
||||||
|
use Openguru\OpenCartFramework\TeleCartPulse\TeleCartPulseService;
|
||||||
|
|
||||||
|
class TelemetryHandler
|
||||||
|
{
|
||||||
|
private TeleCartPulseService $teleCartPulseService;
|
||||||
|
|
||||||
|
public function __construct(TeleCartPulseService $teleCartPulseService)
|
||||||
|
{
|
||||||
|
$this->teleCartPulseService = $teleCartPulseService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws PulseIngestException
|
||||||
|
*/
|
||||||
|
public function ingest(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->teleCartPulseService->handleIngest($request->json());
|
||||||
|
|
||||||
|
return new JsonResponse([], Response::HTTP_NO_CONTENT);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
use App\Handlers\BlocksHandler;
|
use App\Handlers\BlocksHandler;
|
||||||
use App\Handlers\CartHandler;
|
use App\Handlers\CartHandler;
|
||||||
use App\Handlers\CategoriesHandler;
|
use App\Handlers\CategoriesHandler;
|
||||||
|
use App\Handlers\ETLHandler;
|
||||||
use App\Handlers\FiltersHandler;
|
use App\Handlers\FiltersHandler;
|
||||||
use App\Handlers\FormsHandler;
|
use App\Handlers\FormsHandler;
|
||||||
use App\Handlers\HealthCheckHandler;
|
use App\Handlers\HealthCheckHandler;
|
||||||
@@ -12,6 +13,7 @@ use App\Handlers\ProductsHandler;
|
|||||||
use App\Handlers\SettingsHandler;
|
use App\Handlers\SettingsHandler;
|
||||||
use App\Handlers\TelegramCustomerHandler;
|
use App\Handlers\TelegramCustomerHandler;
|
||||||
use App\Handlers\TelegramHandler;
|
use App\Handlers\TelegramHandler;
|
||||||
|
use App\Handlers\TelemetryHandler;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'categoriesList' => [CategoriesHandler::class, 'index'],
|
'categoriesList' => [CategoriesHandler::class, 'index'],
|
||||||
@@ -21,6 +23,7 @@ return [
|
|||||||
'getCart' => [CartHandler::class, 'index'],
|
'getCart' => [CartHandler::class, 'index'],
|
||||||
'getForm' => [FormsHandler::class, 'getForm'],
|
'getForm' => [FormsHandler::class, 'getForm'],
|
||||||
'health' => [HealthCheckHandler::class, 'handle'],
|
'health' => [HealthCheckHandler::class, 'handle'],
|
||||||
|
'ingest' => [TelemetryHandler::class, 'ingest'],
|
||||||
'manifest' => [SettingsHandler::class, 'manifest'],
|
'manifest' => [SettingsHandler::class, 'manifest'],
|
||||||
'processBlock' => [BlocksHandler::class, 'processBlock'],
|
'processBlock' => [BlocksHandler::class, 'processBlock'],
|
||||||
'product_show' => [ProductsHandler::class, 'show'],
|
'product_show' => [ProductsHandler::class, 'show'],
|
||||||
@@ -31,4 +34,6 @@ return [
|
|||||||
'testTgMessage' => [SettingsHandler::class, 'testTgMessage'],
|
'testTgMessage' => [SettingsHandler::class, 'testTgMessage'],
|
||||||
'userPrivacyConsent' => [PrivacyPolicyHandler::class, 'userPrivacyConsent'],
|
'userPrivacyConsent' => [PrivacyPolicyHandler::class, 'userPrivacyConsent'],
|
||||||
'webhook' => [TelegramHandler::class, 'webhook'],
|
'webhook' => [TelegramHandler::class, 'webhook'],
|
||||||
|
'etlCustomers' => [ETLHandler::class, 'customers'],
|
||||||
|
'etlCustomersMeta' => [ETLHandler::class, 'getCustomersMeta'],
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user