Snapshot of main at 9f39a6e1e (2026-11-02)
This commit is contained in:
commit
6ccf1792d7
2580 files changed
+1040202
No files matched your search
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Call;
|
||||
use App\Models\Transaction;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class CallCreditService
|
||||
{
|
||||
/**
|
||||
* Check whether a profile has sufficient credits to publish calls.
|
||||
* Uses the same budget logic as Pay.php: balance - limit_min > 0.
|
||||
*/
|
||||
public static function profileHasCredits(string $profileType, int $profileId): bool
|
||||
{
|
||||
$profile = $profileType::find($profileId);
|
||||
if (!$profile) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$accounts = $profile->accounts()->notRemoved()->get();
|
||||
if ($accounts->isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($accounts as $account) {
|
||||
$balance = Transaction::where('from_account_id', $account->id)
|
||||
->orWhere('to_account_id', $account->id)
|
||||
->selectRaw('SUM(CASE WHEN to_account_id = ? THEN amount ELSE -amount END) as balance', [$account->id])
|
||||
->value('balance') ?? 0;
|
||||
|
||||
if (($balance - $account->limit_min) > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause all active calls for a profile (set till = now, remove from index).
|
||||
*/
|
||||
public static function pauseAllActiveCalls(string $profileType, int $profileId): void
|
||||
{
|
||||
$activeCalls = Call::where('callable_type', $profileType)
|
||||
->where('callable_id', $profileId)
|
||||
->where(fn ($q) => $q->whereNull('till')->orWhere('till', '>', now()))
|
||||
->get();
|
||||
|
||||
foreach ($activeCalls as $call) {
|
||||
$call->update(['till' => now()]);
|
||||
$call->unsearchable();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Enflow\SocialShare\SocialShare;
|
||||
|
||||
class CustomSocialShare extends SocialShare
|
||||
{
|
||||
public function __call(string $service, array $parameters = []): self
|
||||
{
|
||||
$this->services->push(new CustomSocialShareService($this, $service, $parameters));
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Enflow\SocialShare\ConfiguredSocialShareService;
|
||||
|
||||
class CustomSocialShareService extends ConfiguredSocialShareService
|
||||
{
|
||||
public function icon(): ?string
|
||||
{
|
||||
// First check for custom icon in resources
|
||||
$customIconPath = resource_path('views/vendor/social-share/icons/' . $this->name . '.svg');
|
||||
if (file_exists($customIconPath)) {
|
||||
return file_get_contents($customIconPath);
|
||||
}
|
||||
|
||||
// Fallback to vendor package icon
|
||||
if (file_exists($iconPath = __DIR__ . '/../../vendor/enflow/laravel-social-share/resources/icons/' . $this->name . '.svg')) {
|
||||
return file_get_contents($iconPath);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* A text logo with the platform name, for platforms that have not uploaded their own email logo.
|
||||
*
|
||||
* Emails and PDFs need a PNG (many mail clients do not show SVG), so the name is drawn with GD in the
|
||||
* theme's logo colour, or white for dark backgrounds, and cached under storage/app/public. A new name
|
||||
* or colour gives a new file.
|
||||
*/
|
||||
class GeneratedLogo
|
||||
{
|
||||
/** Bump to redraw every generated logo after a change to the drawing below. */
|
||||
private const VERSION = 4;
|
||||
|
||||
private const WIDTH = 900;
|
||||
private const HEIGHT = 700;
|
||||
private const DIR = 'app-images/generated';
|
||||
|
||||
/**
|
||||
* The logo's path relative to storage/app/public, drawn on first use. Null when it cannot be drawn
|
||||
* (no GD with FreeType, the font is missing, or storage is not writable).
|
||||
*/
|
||||
public static function path(bool $inverted = false): ?string
|
||||
{
|
||||
$name = trim((string) platform_name());
|
||||
if ($name === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$color = $inverted ? '#FFFFFF' : (theme_color('logo') ?: theme_color('brand') ?: '#000000');
|
||||
$font = public_path('fonts/static/roboto/Roboto-Bold.ttf');
|
||||
|
||||
$hash = substr(md5(implode('|', [$name, $color, self::VERSION])), 0, 10);
|
||||
$file = self::DIR . '/' . (Str::slug($name) ?: 'platform') . ($inverted ? '-inverted' : '') . "-{$hash}.png";
|
||||
$absolute = storage_path('app/public/' . $file);
|
||||
|
||||
if (is_file($absolute)) {
|
||||
return $file;
|
||||
}
|
||||
|
||||
if (!function_exists('imagettfbbox') || !is_file($font)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!is_dir(dirname($absolute)) && !mkdir(dirname($absolute), 0775, true) && !is_dir(dirname($absolute))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$png = self::draw($name, $color, $font);
|
||||
|
||||
// Written to a temporary name first, so a parallel request never serves a half-written file
|
||||
$temporary = $absolute . '.' . Str::random(8) . '.tmp';
|
||||
if ($png === null || file_put_contents($temporary, $png) === false || !rename($temporary, $absolute)) {
|
||||
@unlink($temporary);
|
||||
return null;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
return null;
|
||||
}
|
||||
|
||||
return $file;
|
||||
}
|
||||
|
||||
/** The name centred on a transparent canvas, split over the lines that allow the largest letters. */
|
||||
private static function draw(string $name, string $hexColor, string $font): ?string
|
||||
{
|
||||
$lines = [$name];
|
||||
$size = 0;
|
||||
foreach (self::layouts($name) as $candidate) {
|
||||
$fit = self::fittingSize($candidate, $font);
|
||||
// Fewer lines win a tie (layouts() lists them first); more lines must be clearly larger
|
||||
if ($fit > $size * 1.1) {
|
||||
[$lines, $size] = [$candidate, $fit];
|
||||
}
|
||||
}
|
||||
|
||||
$image = imagecreatetruecolor(self::WIDTH, self::HEIGHT);
|
||||
if ($image === false) {
|
||||
return null;
|
||||
}
|
||||
imagesavealpha($image, true);
|
||||
imagealphablending($image, false);
|
||||
imagefill($image, 0, 0, imagecolorallocatealpha($image, 0, 0, 0, 127));
|
||||
imagealphablending($image, true);
|
||||
|
||||
[$r, $g, $b] = sscanf(ltrim($hexColor, '#'), '%02x%02x%02x');
|
||||
$color = imagecolorallocate($image, $r ?? 0, $g ?? 0, $b ?? 0);
|
||||
|
||||
$lineHeight = $size * 1.18;
|
||||
$top = (self::HEIGHT - $lineHeight * count($lines)) / 2;
|
||||
foreach ($lines as $i => $line) {
|
||||
$box = imagettfbbox($size, 0, $font, $line);
|
||||
$lineWidth = $box[2] - $box[0];
|
||||
$x = (int) round((self::WIDTH - $lineWidth) / 2 - $box[0]);
|
||||
// Baseline: the line's box, plus the font's ascent (about 0.93 of the size for Roboto)
|
||||
$y = (int) round($top + $i * $lineHeight + $size * 0.93 + ($lineHeight - $size * 1.17) / 2);
|
||||
imagettftext($image, $size, 0, $x, $y, $color, $font, $line);
|
||||
}
|
||||
|
||||
ob_start();
|
||||
imagepng($image);
|
||||
imagedestroy($image);
|
||||
|
||||
return ob_get_clean() ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The name on one line, then every split into two lines, then into three. Lines break at spaces
|
||||
* and after hyphens (Rotterdam-|Zuid keeps its hyphen at the end of the line).
|
||||
*/
|
||||
private static function layouts(string $name): array
|
||||
{
|
||||
// Parts and the glue that goes before each part when they stay on one line
|
||||
$parts = [];
|
||||
$glue = [];
|
||||
foreach (preg_split('/\s+/u', $name) as $w => $word) {
|
||||
foreach (preg_split('/(?<=-)(?=.)/u', $word) as $p => $part) {
|
||||
$glue[] = $p > 0 ? '' : ($w > 0 ? ' ' : '');
|
||||
$parts[] = $part;
|
||||
}
|
||||
}
|
||||
// A long single word without hyphens: hyphenate it into two or three equal chunks as a last resort
|
||||
if (count($parts) === 1 && mb_strlen($name) > 16) {
|
||||
$layouts = [[$name]];
|
||||
foreach ([2, 3] as $chunks) {
|
||||
$length = (int) ceil(mb_strlen($name) / $chunks);
|
||||
$lines = [];
|
||||
for ($c = 0; $c < $chunks; $c++) {
|
||||
$lines[] = mb_substr($name, $c * $length, $length) . ($c < $chunks - 1 ? '-' : '');
|
||||
}
|
||||
$layouts[] = array_values(array_filter($lines, fn ($l) => $l !== '' && $l !== '-'));
|
||||
}
|
||||
return $layouts;
|
||||
}
|
||||
|
||||
$count = count($parts);
|
||||
$join = function (int $from, int $to) use ($parts, $glue) {
|
||||
$line = '';
|
||||
for ($k = $from; $k < $to; $k++) {
|
||||
$line .= ($k > $from ? $glue[$k] : '') . $parts[$k];
|
||||
}
|
||||
return $line;
|
||||
};
|
||||
|
||||
$layouts = [[$name]];
|
||||
for ($i = 1; $i < $count; $i++) {
|
||||
$layouts[] = [$join(0, $i), $join($i, $count)];
|
||||
}
|
||||
for ($i = 1; $i < $count; $i++) {
|
||||
for ($j = $i + 1; $j < $count; $j++) {
|
||||
$layouts[] = [$join(0, $i), $join($i, $j), $join($j, $count)];
|
||||
}
|
||||
}
|
||||
|
||||
return $layouts;
|
||||
}
|
||||
|
||||
/** The largest font size at which every line fits 84% of the width and all lines 64% of the height. */
|
||||
private static function fittingSize(array $lines, string $font): int
|
||||
{
|
||||
for ($size = 260; $size > 20; $size -= 4) {
|
||||
[$width, $height] = self::measure($lines, $font, $size);
|
||||
if ($width <= self::WIDTH * 0.84 && $height <= self::HEIGHT * 0.64) {
|
||||
return $size;
|
||||
}
|
||||
}
|
||||
|
||||
return 20;
|
||||
}
|
||||
|
||||
/** Width of the widest line and the total height, at a font size. */
|
||||
private static function measure(array $lines, string $font, int $size): array
|
||||
{
|
||||
$width = 0;
|
||||
foreach ($lines as $line) {
|
||||
$box = imagettfbbox($size, 0, $font, $line);
|
||||
$width = max($width, $box[2] - $box[0]);
|
||||
}
|
||||
|
||||
return [$width, $size * 1.18 * count($lines)];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Translation\FileLoader;
|
||||
|
||||
/**
|
||||
* What the platform folder (platform_path(), see PlatformServiceProvider) overrides, compared with the code.
|
||||
* Overrides go stale without anyone noticing: a translation key is the English source sentence, so a reworded
|
||||
* sentence leaves its override unused, and a copied template misses later fixes to its original. check() reports
|
||||
* that; platform:override copies a template with a fingerprint of its original, so a later change shows.
|
||||
*/
|
||||
class PlatformOverrides
|
||||
{
|
||||
/** First line of a template copied by platform:override: the original's path and its sha1 at the time. */
|
||||
public const FINGERPRINT = '/^\{\{-- platform:override of (\S+) sha1:([0-9a-f]{40}) --\}\}/';
|
||||
|
||||
public function __construct(private Filesystem $files)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Findings, each ['level' => 'ok'|'info'|'warn'|'fail', 'message' => string].
|
||||
*
|
||||
* @return array<int, array{level: string, message: string}>
|
||||
*/
|
||||
public function check(): array
|
||||
{
|
||||
$root = platform_path();
|
||||
if (!is_dir($root)) {
|
||||
return [['level' => 'ok', 'message' => "No platform folder ({$root}): this installation runs on config/ and the files in the code."]];
|
||||
}
|
||||
|
||||
return [
|
||||
...$this->checkSources(),
|
||||
...$this->checkPublicLink(),
|
||||
...$this->checkTranslations(),
|
||||
...$this->checkTemplates(),
|
||||
];
|
||||
}
|
||||
|
||||
private function checkSources(): array
|
||||
{
|
||||
$found = [];
|
||||
$name = (string) config('app.timebank_config');
|
||||
|
||||
if (is_file(platform_path('config.php'))) {
|
||||
$found[] = is_file(config_path("{$name}.php"))
|
||||
? ['level' => 'warn', 'message' => "Both platform/config.php and config/{$name}.php exist; the platform folder wins. Remove config/{$name}.php once the site runs well."]
|
||||
: ['level' => 'ok', 'message' => 'Platform config: platform/config.php'];
|
||||
} else {
|
||||
$found[] = ['level' => 'info', 'message' => "No platform/config.php: the platform config is config/{$name}.php."];
|
||||
}
|
||||
|
||||
if (is_file(platform_path('theme.php'))) {
|
||||
$theme = require platform_path('theme.php');
|
||||
$found[] = isset($theme['id'])
|
||||
? ['level' => 'ok', 'message' => "Platform theme: platform/theme.php (id {$theme['id']})"]
|
||||
: ['level' => 'warn', 'message' => "platform/theme.php has no 'id': it runs as theme 'platform'. Give it an id."];
|
||||
}
|
||||
|
||||
return $found;
|
||||
}
|
||||
|
||||
private function checkPublicLink(): array
|
||||
{
|
||||
$target = platform_path('public');
|
||||
if (!is_dir($target)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$link = public_path('platform');
|
||||
|
||||
return is_link($link) && readlink($link) === $target
|
||||
? [['level' => 'ok', 'message' => 'Platform public files linked at /platform.']]
|
||||
: [['level' => 'warn', 'message' => 'platform/public is not served: run php artisan platform:link.']];
|
||||
}
|
||||
|
||||
/** The translations as the code alone defines them: every path the translator has, except the platform's. */
|
||||
private function baseLoader(): FileLoader
|
||||
{
|
||||
$loader = app('translator')->getLoader();
|
||||
$platformLang = platform_path('lang');
|
||||
$base = new FileLoader($this->files, array_values(array_filter($loader->paths(), fn ($path) => $path !== $platformLang)));
|
||||
|
||||
foreach ($loader->namespaces() as $namespace => $hint) {
|
||||
$base->addNamespace($namespace, $hint);
|
||||
}
|
||||
foreach ($loader->jsonPaths() as $path) {
|
||||
$base->addJsonPath($path);
|
||||
}
|
||||
|
||||
return $base;
|
||||
}
|
||||
|
||||
private function checkTranslations(): array
|
||||
{
|
||||
$lang = platform_path('lang');
|
||||
if (!is_dir($lang)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$base = $this->baseLoader();
|
||||
$platformViews = $this->platformTemplateSource();
|
||||
$found = [];
|
||||
|
||||
foreach ($this->files->allFiles($lang) as $file) {
|
||||
$relative = str_replace('\\', '/', $file->getRelativePathname());
|
||||
|
||||
if (preg_match('#^([A-Za-z_-]+)\.json$#', $relative, $m)) {
|
||||
$overrides = json_decode($this->files->get($file->getPathname()), true);
|
||||
if (!is_array($overrides)) {
|
||||
$found[] = ['level' => 'fail', 'message' => "lang/{$relative} is not valid JSON: none of its texts are used."];
|
||||
continue;
|
||||
}
|
||||
// A key the platform's own templates use is the platform's own text, not a stale override
|
||||
$baseTexts = $base->load($m[1], '*', '*');
|
||||
$unused = array_values(array_filter(array_keys($overrides), fn ($key) => !array_key_exists($key, $baseTexts)
|
||||
&& !str_contains($platformViews, $key)));
|
||||
} elseif (preg_match('#^vendor/([^/]+)/([^/]+)/(.+)\.php$#', $relative, $m)) {
|
||||
$unused = $this->unusedKeys($file->getPathname(), $base->load($m[2], $m[3], $m[1]));
|
||||
} elseif (preg_match('#^([^/]+)/(.+)\.php$#', $relative, $m)) {
|
||||
$unused = $this->unusedKeys($file->getPathname(), $base->load($m[1], $m[2]));
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($unused) {
|
||||
$found[] = ['level' => 'warn', 'message' => "lang/{$relative}: " . count($unused) . ' key(s) not in the code (reworded or removed there): '
|
||||
. implode(' | ', array_map(fn ($key) => mb_strimwidth($key, 0, 60, '...'), array_slice($unused, 0, 5)))
|
||||
. (count($unused) > 5 ? ' ...' : '')];
|
||||
}
|
||||
}
|
||||
|
||||
return $found ?: [['level' => 'ok', 'message' => 'Translation overrides: none stale.']];
|
||||
}
|
||||
|
||||
private function unusedKeys(string $file, array $base): array
|
||||
{
|
||||
$overrides = require $file;
|
||||
|
||||
return is_array($overrides) ? array_keys(array_diff_key(Arr::dot($overrides), Arr::dot($base))) : [];
|
||||
}
|
||||
|
||||
private function platformTemplateSource(): string
|
||||
{
|
||||
$views = platform_path('views');
|
||||
|
||||
return is_dir($views)
|
||||
? implode("\n", array_map(fn ($file) => $this->files->get($file->getPathname()), $this->files->allFiles($views)))
|
||||
: '';
|
||||
}
|
||||
|
||||
private function checkTemplates(): array
|
||||
{
|
||||
$views = platform_path('views');
|
||||
if (!is_dir($views)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$found = [];
|
||||
foreach ($this->files->allFiles($views) as $file) {
|
||||
$relative = str_replace('\\', '/', $file->getRelativePathname());
|
||||
$original = $this->originalOf($relative);
|
||||
|
||||
if ($original === null) {
|
||||
$found[] = ['level' => 'info', 'message' => "views/{$relative}: the platform's own template (no original in the code)."];
|
||||
continue;
|
||||
}
|
||||
|
||||
$firstLine = strtok($this->files->get($file->getPathname()), "\n");
|
||||
if (!preg_match(self::FINGERPRINT, (string) $firstLine, $m)) {
|
||||
$found[] = ['level' => 'info', 'message' => "views/{$relative}: copied without a fingerprint; cannot tell whether the original changed since (copy with php artisan platform:override)."];
|
||||
} elseif (sha1_file($original) !== $m[2]) {
|
||||
$found[] = ['level' => 'warn', 'message' => "views/{$relative}: the original changed since it was copied. Compare with " . $this->shown($original) . ', then copy the changes over and update the fingerprint.'];
|
||||
}
|
||||
}
|
||||
|
||||
return $found ?: [['level' => 'ok', 'message' => 'Template overrides: none stale.']];
|
||||
}
|
||||
|
||||
/**
|
||||
* The code's file that a platform template overrides, or null. $relative is the path inside platform/views:
|
||||
* mail/<file> for the mail components, vendor/<package>/<file> for package views, else an app view.
|
||||
*/
|
||||
public function originalOf(string $relative): ?string
|
||||
{
|
||||
$platformViews = platform_path('views');
|
||||
|
||||
if (str_starts_with($relative, 'mail/')) {
|
||||
$candidates = [
|
||||
...array_filter(config('mail.markdown.paths', []), fn ($path) => $path !== "{$platformViews}/mail"),
|
||||
base_path('vendor/laravel/framework/src/Illuminate/Mail/resources/views'),
|
||||
];
|
||||
$file = substr($relative, strlen('mail/'));
|
||||
} elseif (preg_match('#^vendor/([^/]+)/(.+)$#', $relative, $m)) {
|
||||
$candidates = array_filter(View::getFinder()->getHints()[$m[1]] ?? [], fn ($path) => !str_starts_with($path, $platformViews));
|
||||
$file = $m[2];
|
||||
} else {
|
||||
$candidates = array_filter(View::getFinder()->getPaths(), fn ($path) => $path !== $platformViews);
|
||||
$file = $relative;
|
||||
}
|
||||
|
||||
foreach ($candidates as $path) {
|
||||
if (is_file("{$path}/{$file}")) {
|
||||
return "{$path}/{$file}";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function shown(string $path): string
|
||||
{
|
||||
return str_starts_with($path, base_path() . '/') ? substr($path, strlen(base_path()) + 1) : $path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
|
||||
// 1. Enhanced Activity Log Service
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
class PresenceService
|
||||
{
|
||||
public const PRESENCE_ACTIVITY = 'presence_update';
|
||||
public const ONLINE_THRESHOLD_MINUTES = 2;
|
||||
|
||||
public function updatePresence($user = null, $guard = null)
|
||||
{
|
||||
$user = $user ?? Auth::user();
|
||||
$guard = $guard ?? Auth::getDefaultDriver();
|
||||
|
||||
if (!$user) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Log activity using Spatie
|
||||
activity(self::PRESENCE_ACTIVITY)
|
||||
->performedOn($user)
|
||||
->causedBy($user)
|
||||
->withProperties([
|
||||
'guard' => $guard,
|
||||
'ip_address' => request()->ip(),
|
||||
'user_agent' => request()->userAgent(),
|
||||
'timestamp' => now(),
|
||||
'status' => 'online',
|
||||
])
|
||||
->event('updated')
|
||||
->log('User online');
|
||||
|
||||
// Cache for quick access
|
||||
$cacheKey = "presence_{$guard}_{$user->id}";
|
||||
Cache::put($cacheKey, [
|
||||
'user_id' => $user->id,
|
||||
'user_type' => get_class($user),
|
||||
'guard' => $guard,
|
||||
'name' => $user->name,
|
||||
'avatar' => $user->avatar ?? null,
|
||||
'last_seen' => now(),
|
||||
'status' => 'online',
|
||||
], now()->addMinutes(self::ONLINE_THRESHOLD_MINUTES + 2));
|
||||
|
||||
// Broadcast presence update
|
||||
broadcast(new \App\Events\UserPresenceUpdated($user, $guard, 'online'));
|
||||
|
||||
// Clear cache for online users to force refresh
|
||||
Cache::forget("online_users_{$guard}_" . self::ONLINE_THRESHOLD_MINUTES);
|
||||
|
||||
// Cleanup old presence records for this user
|
||||
$this->cleanupUserPresenceRecords($user, $guard);
|
||||
}
|
||||
|
||||
public function setUserOffline($user, $guard = 'web')
|
||||
{
|
||||
if (!$user) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Log offline activity
|
||||
activity(self::PRESENCE_ACTIVITY)
|
||||
->performedOn($user)
|
||||
->causedBy($user)
|
||||
->withProperties([
|
||||
'guard' => $guard,
|
||||
'status' => 'offline',
|
||||
'timestamp' => now(),
|
||||
])
|
||||
->log('User offline');
|
||||
|
||||
// Remove from cache
|
||||
$cacheKey = "presence_{$guard}_{$user->id}";
|
||||
Cache::forget($cacheKey);
|
||||
|
||||
// Broadcast offline status
|
||||
broadcast(new \App\Events\UserPresenceUpdated($user, $guard, 'offline'));
|
||||
|
||||
// Clear online users cache
|
||||
Cache::forget("online_users_{$guard}_" . self::ONLINE_THRESHOLD_MINUTES);
|
||||
|
||||
// Don't cleanup on offline - preserve presence history
|
||||
}
|
||||
|
||||
public function getOnlineUsers($guard = 'web', $minutes = null)
|
||||
{
|
||||
$minutes = $minutes ?? self::ONLINE_THRESHOLD_MINUTES;
|
||||
$cacheKey = "online_users_{$guard}_{$minutes}";
|
||||
|
||||
return Cache::remember($cacheKey, 30, function () use ($guard, $minutes) {
|
||||
try {
|
||||
// Simple and reliable database query
|
||||
$activities = Activity::where('log_name', self::PRESENCE_ACTIVITY)
|
||||
->where('properties->guard', $guard)
|
||||
->where('created_at', '>=', now()->subMinutes($minutes))
|
||||
->with('subject')
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
// Group by user and get the latest activity for each
|
||||
$userActivities = $activities->groupBy('subject_id')->map(function ($userActivities) {
|
||||
return $userActivities->first(); // Get the most recent activity
|
||||
});
|
||||
|
||||
// Filter out offline users and map to user data
|
||||
$onlineUsers = $userActivities->map(function ($activity) {
|
||||
$user = $activity->subject;
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Skip if latest activity is "offline"
|
||||
$properties = is_string($activity->properties) ? json_decode($activity->properties, true) : $activity->properties;
|
||||
if (isset($properties['status']) && $properties['status'] === 'offline') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'avatar' => $user->avatar ?? null,
|
||||
'guard' => $properties['guard'] ?? $guard,
|
||||
'last_seen' => $activity->created_at,
|
||||
'user_type' => $activity->subject_type,
|
||||
'status' => $properties['status'] ?? 'online',
|
||||
];
|
||||
})->filter()->values();
|
||||
|
||||
return $onlineUsers;
|
||||
} catch (\Exception $e) {
|
||||
// Return empty collection on error
|
||||
\Log::error('PresenceService getOnlineUsers error: ' . $e->getMessage());
|
||||
return collect([]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function isUserOnline($user, $guard = 'web', $minutes = null)
|
||||
{
|
||||
$minutes = $minutes ?? self::ONLINE_THRESHOLD_MINUTES;
|
||||
$cacheKey = "presence_{$guard}_{$user->id}";
|
||||
|
||||
// Check cache first — also verify last_seen is within the threshold
|
||||
$cachedData = Cache::get($cacheKey);
|
||||
if ($cachedData && isset($cachedData['status']) && $cachedData['status'] === 'online') {
|
||||
$lastSeen = $cachedData['last_seen'] ?? null;
|
||||
if ($lastSeen) {
|
||||
$lastSeenTime = $lastSeen instanceof \Carbon\Carbon ? $lastSeen : \Carbon\Carbon::parse($lastSeen);
|
||||
if ((int) ($lastSeenTime->diffInMinutes(now(), true)) <= $minutes) {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the most recent presence activity for this user
|
||||
$latestActivity = Activity::where('log_name', self::PRESENCE_ACTIVITY)
|
||||
->where('subject_id', $user->id)
|
||||
->where('subject_type', get_class($user))
|
||||
->where('properties->guard', $guard)
|
||||
->where('created_at', '>=', now()->subMinutes($minutes))
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
if (!$latestActivity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the latest activity is "offline"
|
||||
$properties = is_string($latestActivity->properties)
|
||||
? json_decode($latestActivity->properties, true)
|
||||
: $latestActivity->properties;
|
||||
|
||||
return isset($properties['status']) && $properties['status'] === 'online';
|
||||
}
|
||||
|
||||
public function getUserLastSeen($user, $guard = 'web')
|
||||
{
|
||||
$activity = Activity::where('log_name', self::PRESENCE_ACTIVITY)
|
||||
->where('subject_id', $user->id)
|
||||
->where('subject_type', get_class($user))
|
||||
->where('properties->guard', $guard)
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
return $activity ? $activity->created_at : null;
|
||||
}
|
||||
|
||||
public function getPresenceStats($guard = 'web')
|
||||
{
|
||||
$onlineUsers = $this->getOnlineUsers($guard);
|
||||
|
||||
return [
|
||||
'online_count' => $onlineUsers->count(),
|
||||
'online_users' => $onlineUsers,
|
||||
'guard' => $guard,
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old presence records for a specific user, keeping only the latest, as defined in config
|
||||
*/
|
||||
protected function cleanupUserPresenceRecords($user, $guard = 'web')
|
||||
{
|
||||
$keepCount = timebank_config('presence_settings.keep_last_presence_updates', timebank_config('presence_settings.keep_last_presence_updates'));
|
||||
|
||||
$userActivities = Activity::where('log_name', self::PRESENCE_ACTIVITY)
|
||||
->where('causer_id', $user->id)
|
||||
->where('causer_type', get_class($user))
|
||||
->where('properties->guard', $guard)
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
if ($userActivities->count() > $keepCount) {
|
||||
$toDelete = $userActivities->skip($keepCount)->pluck('id');
|
||||
Activity::whereIn('id', $toDelete)->delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Stats;
|
||||
|
||||
/**
|
||||
* Resolves a set of location selections down to the single most specific level.
|
||||
*
|
||||
* Combining levels with OR would let a broader level (e.g. country) swallow a
|
||||
* narrower one (e.g. division), making the narrower filter ineffective — so only
|
||||
* the deepest level that has selections is applied.
|
||||
*/
|
||||
class LocationFilterResolver
|
||||
{
|
||||
/**
|
||||
* @return array{column: string, ids: array<int>}|null null when nothing is selected
|
||||
*/
|
||||
public static function resolve(array $countryIds, array $divisionIds, array $cityIds, array $districtIds): ?array
|
||||
{
|
||||
foreach ([
|
||||
'district_id' => $districtIds,
|
||||
'city_id' => $cityIds,
|
||||
'division_id' => $divisionIds,
|
||||
'country_id' => $countryIds,
|
||||
] as $column => $ids) {
|
||||
$ids = array_values(array_filter(array_map('intval', $ids)));
|
||||
if (!empty($ids)) {
|
||||
return ['column' => $column, 'ids' => $ids];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constrain a profile query to profiles whose location matches the deepest selected level.
|
||||
*/
|
||||
public static function apply($query, array $countryIds, array $divisionIds, array $cityIds, array $districtIds)
|
||||
{
|
||||
$resolved = self::resolve($countryIds, $divisionIds, $cityIds, $districtIds);
|
||||
|
||||
if ($resolved === null) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->whereHas('locations', function ($q) use ($resolved) {
|
||||
$q->whereIn($resolved['column'], $resolved['ids']);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Stats;
|
||||
|
||||
/**
|
||||
* Which keys occur inside a date window that only ever moves forward.
|
||||
*
|
||||
* Built for the network time-lapse: its layers are windows over a day-ordered
|
||||
* list of [day, key] events - "traded in the month ending on this step",
|
||||
* "traded before this date" - and each step moves them on by a week or a
|
||||
* month. Two pointers walk the events once in total, so all steps together
|
||||
* cost one pass, and each step reports only the keys that entered or left.
|
||||
*
|
||||
* Days are 'Y-m-d' strings, which compare correctly as strings.
|
||||
*/
|
||||
class SlidingWindow
|
||||
{
|
||||
/** @var array<string,int> Occurrences of each key inside the window. */
|
||||
private array $count = [];
|
||||
|
||||
private int $enter = 0;
|
||||
|
||||
private int $leave = 0;
|
||||
|
||||
private int $size;
|
||||
|
||||
/** @param array<array{0:string, 1:string}> $events [day, key], ordered by day */
|
||||
public function __construct(private array $events)
|
||||
{
|
||||
$this->size = count($events);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the window to [$start, $end] (inclusive; null start = no lower
|
||||
* bound) and return what entered and left since the previous call.
|
||||
*
|
||||
* Both bounds may only move forward.
|
||||
*
|
||||
* @return array{added: array<string>, removed: array<string>}
|
||||
*/
|
||||
public function slideTo(?string $start, string $end): array
|
||||
{
|
||||
$before = [];
|
||||
|
||||
while ($this->enter < $this->size && $this->events[$this->enter][0] <= $end) {
|
||||
$key = $this->events[$this->enter++][1];
|
||||
$before[$key] ??= ($this->count[$key] ?? 0) > 0;
|
||||
$this->count[$key] = ($this->count[$key] ?? 0) + 1;
|
||||
}
|
||||
|
||||
if ($start !== null) {
|
||||
while ($this->leave < $this->enter && $this->events[$this->leave][0] < $start) {
|
||||
$key = $this->events[$this->leave++][1];
|
||||
$before[$key] ??= ($this->count[$key] ?? 0) > 0;
|
||||
|
||||
if (--$this->count[$key] === 0) {
|
||||
unset($this->count[$key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$added = [];
|
||||
$removed = [];
|
||||
|
||||
foreach ($before as $key => $was) {
|
||||
$now = isset($this->count[$key]);
|
||||
|
||||
if ($now && !$was) {
|
||||
$added[] = $key;
|
||||
} elseif (!$now && $was) {
|
||||
$removed[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
return ['added' => $added, 'removed' => $removed];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Stats;
|
||||
|
||||
use App\Models\Bank;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
|
||||
/**
|
||||
* Immutable description of a Stats query: which profiles, over which period.
|
||||
*
|
||||
* Filters combine with AND. An empty filter means "nothing selected yet" — the
|
||||
* Stats view renders its empty state rather than querying the whole platform.
|
||||
*/
|
||||
final class StatsFilter
|
||||
{
|
||||
/**
|
||||
* Profile types that take part in platform statistics.
|
||||
*
|
||||
* Admin is deliberately excluded: admins hold no accounts, and counting them
|
||||
* as community members would skew registration and engagement figures.
|
||||
*/
|
||||
public const PROFILE_TYPES = [User::class, Organization::class, Bank::class];
|
||||
|
||||
public function __construct(
|
||||
public readonly ?Carbon $periodFrom = null,
|
||||
public readonly ?Carbon $periodTill = null,
|
||||
public readonly array $countryIds = [],
|
||||
public readonly array $divisionIds = [],
|
||||
public readonly array $cityIds = [],
|
||||
public readonly array $districtIds = [],
|
||||
public readonly ?Carbon $registeredFrom = null,
|
||||
public readonly ?Carbon $registeredTill = null,
|
||||
/** @var array<array{id:int,type:class-string}> */
|
||||
public readonly array $counterparties = [],
|
||||
/**
|
||||
* Sort for the "most active profiles" table. It belongs here rather
|
||||
* than in the view because the table is a top-20 cut: sorting after the
|
||||
* fact would only reorder those twenty, not find the real top payers.
|
||||
*/
|
||||
public readonly string $topSort = 'count',
|
||||
public readonly string $topDirection = 'desc',
|
||||
/** Same, for the "activity by location" table, which sorts separately. */
|
||||
public readonly string $locationSort = 'count',
|
||||
public readonly string $locationDirection = 'desc',
|
||||
/**
|
||||
* "All time" chosen deliberately. Without it, null dates are
|
||||
* indistinguishable from "nothing selected yet" and isEmpty() would
|
||||
* send the page to its empty state instead of reporting on everything.
|
||||
*/
|
||||
public readonly bool $allTime = false,
|
||||
/**
|
||||
* Profiles that have not logged in since this date.
|
||||
*
|
||||
* An upper bound, not a range: "not logged in since X" means
|
||||
* last_login_at < X, or never logged in at all. Hence a single date.
|
||||
*/
|
||||
public readonly ?Carbon $notLoggedInSince = null,
|
||||
/**
|
||||
* Profiles without a transaction since this date. Same shape as the
|
||||
* above, and deliberately separate from periodFrom: that bounds which
|
||||
* transactions count, this one bounds who is in the selection.
|
||||
*/
|
||||
public readonly ?Carbon $noTransactionSince = null,
|
||||
|
||||
/**
|
||||
* Include profiles their owner has closed (deleted_at set).
|
||||
*
|
||||
* Off by default: the graph describes the live network. On, it also
|
||||
* shows where hours went when a profile was closed - all 134 currency
|
||||
* removals on dev are balance transfers to a bank made during closure,
|
||||
* so they are invisible without this.
|
||||
*/
|
||||
public readonly bool $includeRemoved = false,
|
||||
|
||||
/**
|
||||
* Mark profiles that carry inactive_at with a blue outline.
|
||||
*
|
||||
* NOT an inclusion filter, despite the toggle sitting next to one:
|
||||
* notRemoved() only tests deleted_at, so inactive profiles were never
|
||||
* excluded in the first place. This just asks for them to be
|
||||
* identified.
|
||||
*/
|
||||
public readonly bool $markInactive = false,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* A copy of this filter with a different period.
|
||||
*
|
||||
* Every other criterion is carried over, so a time-lapse frame keeps the
|
||||
* location, registration and counterparty filters the user set - only the
|
||||
* dates move. Readonly promoted properties cannot be reassigned, hence a
|
||||
* new instance rather than a mutation.
|
||||
*/
|
||||
public function withPeriod(?Carbon $from, ?Carbon $till): self
|
||||
{
|
||||
return new self(
|
||||
periodFrom: $from,
|
||||
periodTill: $till,
|
||||
countryIds: $this->countryIds,
|
||||
divisionIds: $this->divisionIds,
|
||||
cityIds: $this->cityIds,
|
||||
districtIds: $this->districtIds,
|
||||
registeredFrom: $this->registeredFrom,
|
||||
registeredTill: $this->registeredTill,
|
||||
counterparties: $this->counterparties,
|
||||
topSort: $this->topSort,
|
||||
topDirection: $this->topDirection,
|
||||
locationSort: $this->locationSort,
|
||||
locationDirection: $this->locationDirection,
|
||||
// Deliberately NOT carried: allTime means "no period at all", which
|
||||
// contradicts the explicit dates being set here.
|
||||
notLoggedInSince: $this->notLoggedInSince,
|
||||
noTransactionSince: $this->noTransactionSince,
|
||||
includeRemoved: $this->includeRemoved,
|
||||
markInactive: $this->markInactive,
|
||||
);
|
||||
}
|
||||
|
||||
/** Columns the top-profiles table may be ordered by. */
|
||||
public const TOP_SORTS = [
|
||||
'paid_count', 'received_count', 'paid_volume', 'received_volume', 'count', 'partners',
|
||||
];
|
||||
|
||||
/**
|
||||
* The sort column as an SQL alias, guarded against anything off the
|
||||
* allow-list.
|
||||
*
|
||||
* "count" is the UI's name for the combined figure; the query aliases it
|
||||
* "cnt", because COUNT is reserved. Mapping here keeps that detail out of
|
||||
* the view.
|
||||
*/
|
||||
public function topSortColumn(): string
|
||||
{
|
||||
$column = in_array($this->topSort, self::TOP_SORTS, true) ? $this->topSort : 'count';
|
||||
|
||||
return $column === 'count' ? 'cnt' : $column;
|
||||
}
|
||||
|
||||
public function topSortDirection(): string
|
||||
{
|
||||
return strtolower($this->topDirection) === 'asc' ? 'asc' : 'desc';
|
||||
}
|
||||
|
||||
/** As topSortColumn(), for the location table. */
|
||||
public function locationSortColumn(): string
|
||||
{
|
||||
$column = in_array($this->locationSort, self::TOP_SORTS, true) ? $this->locationSort : 'count';
|
||||
|
||||
return $column === 'count' ? 'cnt' : $column;
|
||||
}
|
||||
|
||||
public function locationSortDirection(): string
|
||||
{
|
||||
return strtolower($this->locationDirection) === 'asc' ? 'asc' : 'desc';
|
||||
}
|
||||
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
$date = fn(?string $value) => $value ? Carbon::parse($value) : null;
|
||||
|
||||
return new self(
|
||||
periodFrom: $date($data['periodFrom'] ?? null),
|
||||
periodTill: $date($data['periodTill'] ?? null),
|
||||
countryIds: $data['countryIds'] ?? [],
|
||||
divisionIds: $data['divisionIds'] ?? [],
|
||||
cityIds: $data['cityIds'] ?? [],
|
||||
districtIds: $data['districtIds'] ?? [],
|
||||
registeredFrom: $date($data['registeredFrom'] ?? null),
|
||||
registeredTill: $date($data['registeredTill'] ?? null),
|
||||
counterparties: $data['counterparties'] ?? [],
|
||||
topSort: $data['topSort'] ?? 'count',
|
||||
topDirection: $data['topDirection'] ?? 'desc',
|
||||
locationSort: $data['locationSort'] ?? 'count',
|
||||
locationDirection: $data['locationDirection'] ?? 'desc',
|
||||
allTime: (bool) ($data['allTime'] ?? false),
|
||||
);
|
||||
}
|
||||
|
||||
public function hasLocationFilter(): bool
|
||||
{
|
||||
return LocationFilterResolver::resolve(
|
||||
$this->countryIds,
|
||||
$this->divisionIds,
|
||||
$this->cityIds,
|
||||
$this->districtIds
|
||||
) !== null;
|
||||
}
|
||||
|
||||
public function hasRegistrationFilter(): bool
|
||||
{
|
||||
return $this->registeredFrom !== null || $this->registeredTill !== null;
|
||||
}
|
||||
|
||||
public function hasPeriod(): bool
|
||||
{
|
||||
return $this->periodFrom !== null || $this->periodTill !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when nothing has been selected — the caller should render an empty
|
||||
* state instead of building a service and querying the entire platform.
|
||||
*/
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return !$this->allTime
|
||||
&& !$this->hasPeriod()
|
||||
&& !$this->hasLocationFilter()
|
||||
&& !$this->hasRegistrationFilter()
|
||||
&& $this->notLoggedInSince === null
|
||||
&& $this->noTransactionSince === null
|
||||
&& empty($this->counterparties);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable cache key for this exact filter combination.
|
||||
*/
|
||||
public function hash(): string
|
||||
{
|
||||
return md5(serialize([
|
||||
$this->periodFrom?->toDateString(),
|
||||
$this->periodTill?->toDateString(),
|
||||
$this->countryIds,
|
||||
$this->divisionIds,
|
||||
$this->cityIds,
|
||||
$this->districtIds,
|
||||
$this->registeredFrom?->toDateString(),
|
||||
$this->registeredTill?->toDateString(),
|
||||
$this->counterparties,
|
||||
$this->topSortColumn(),
|
||||
$this->topSortDirection(),
|
||||
$this->locationSortColumn(),
|
||||
$this->locationSortDirection(),
|
||||
$this->allTime,
|
||||
$this->notLoggedInSince?->toDateString(),
|
||||
$this->noTransactionSince?->toDateString(),
|
||||
$this->includeRemoved,
|
||||
$this->markInactive,
|
||||
]));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user