Snapshot of main at 9f39a6e1e (2026-11-02)

This commit is contained in:
Ronald Huynen committed 2026-11-02 10:41:47 +01:00
commit 6ccf1792d7
2580 files changed
+1040202

No files matched your search

+56
View File
@@ -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();
}
}
}
+15
View File
@@ -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;
}
}
+24
View File
@@ -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;
}
}
+188
View File
@@ -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)];
}
}
+220
View File
@@ -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;
}
}
+228
View File
@@ -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']);
});
}
}
+77
View File
@@ -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];
}
}
+242
View File
@@ -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