initial: bootstrap from BukidBountyApp base

This commit is contained in:
Jonathan Sykes
2026-06-06 18:43:00 +08:00
commit eb4a5731fb
5674 changed files with 160857 additions and 0 deletions

View File

@@ -0,0 +1,296 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Models\Accounting\Account;
use App\Models\SystemSetting;
use Hypervel\Support\Facades\Config;
use Hypervel\Support\Str;
/**
* Accounting theme registry.
*
* Themes are defined in config/accounting/themes.php and identified by the
* `accounting_theme` system setting. Theme identity travels with the data:
* accounts seeded from a theme are stamped with theme_key + theme_account_code
* so we can branch on the active theme and detect drift later, even after
* users edit the chart through admin CRUD.
*/
class AccountingTheme
{
public const DEFAULT_KEY = 'blank';
public const FALLBACK_KEY = 'blank';
/**
* Active theme key for this deployment.
*/
public static function current(): string
{
$key = SystemSetting::getValue('accounting_theme', self::DEFAULT_KEY);
if (!\is_string($key) || $key === '' || !\array_key_exists($key, self::all())) {
return self::FALLBACK_KEY;
}
return $key;
}
/**
* Whether the active theme matches the given key.
*/
public static function is(string $key): bool
{
return self::current() === $key;
}
/**
* All registered theme definitions, keyed by theme key.
*
* @return array<string, array<string, mixed>>
*/
public static function all(): array
{
$themes = Config::get('accounting.themes', []);
return \is_array($themes) ? $themes : [];
}
/**
* The definition for a single theme (or the active theme when no key given).
*
* @return array<string, mixed>|null
*/
public static function definition(?string $key = null): ?array
{
$key = $key ?? self::current();
$themes = self::all();
return $themes[$key] ?? null;
}
/**
* The starter Chart of Accounts hierarchy for a theme.
*
* @return array<int, array<string, mixed>>
*/
public static function tree(?string $key = null): array
{
$definition = self::definition($key);
$tree = $definition['tree'] ?? [];
return \is_array($tree) ? $tree : [];
}
/**
* Convert an account name to a stable code for the given theme.
* Used so re-applying a theme can match existing seeded accounts even if
* a user has renamed them.
*/
public static function accountCode(string $name): string
{
$slug = strtolower(trim($name));
$slug = preg_replace('/[^a-z0-9]+/', '_', $slug) ?? '';
return trim($slug, '_');
}
/**
* Flatten a theme tree into a list of definitions keyed by code.
*
* Each entry: ['code', 'name', 'type', 'default_flow', 'parent_code', 'description'].
*
* @return array<string, array<string, mixed>>
*/
public static function flatten(?string $key = null): array
{
$flat = [];
self::flattenWalk(self::tree($key), null, $flat);
return $flat;
}
private static function flattenWalk(array $nodes, ?string $parentCode, array &$flat): void
{
foreach ($nodes as $node) {
$name = (string) ($node['name'] ?? '');
if ($name === '') {
continue;
}
$code = self::accountCode($name);
$type = strtoupper((string) ($node['type'] ?? ''));
$flat[$code] = [
'code' => $code,
'name' => $name,
'type' => $type,
'default_flow' => $node['default_flow'] ?? (($type === 'REVENUE' || $type === 'LIABILITY') ? 'INCOME' : 'EXPENSE'),
'parent_code' => $parentCode,
'description' => $node['description'] ?? null,
];
if (!empty($node['children']) && \is_array($node['children'])) {
self::flattenWalk($node['children'], $code, $flat);
}
}
}
/**
* Compare the active theme against the DB. Pure read — never mutates.
*
* Returns:
* - missing: codes in the theme but absent from DB (need apply)
* - present: codes in both, name matches
* - renamed: codes in both but name differs in DB (user-edited)
* - user_added: account ids/names with theme_key NULL (custom additions)
*
* @return array<string, mixed>
*/
public static function drift(?string $key = null): array
{
$key = $key ?? self::current();
$themeAccounts = self::flatten($key);
$rows = Account::where('theme_key', $key)
->get(['id', 'name', 'theme_account_code', 'is_active', 'parent_id'])
->keyBy('theme_account_code');
$missing = [];
$present = [];
$renamed = [];
foreach ($themeAccounts as $code => $def) {
$row = $rows[$code] ?? null;
if (!$row) {
$missing[] = ['code' => $code, 'name' => $def['name']];
continue;
}
if ($row->name !== $def['name']) {
$renamed[] = ['code' => $code, 'expected' => $def['name'], 'current' => $row->name, 'id' => $row->id];
} else {
$present[] = ['code' => $code, 'name' => $def['name'], 'id' => $row->id];
}
}
$userAdded = Account::whereNull('theme_key')
->orderBy('id')
->get(['id', 'name', 'parent_id'])
->map(fn($r) => ['id' => $r->id, 'name' => $r->name, 'parent_id' => $r->parent_id])
->all();
return [
'theme_key' => $key,
'missing' => $missing,
'present' => $present,
'renamed' => $renamed,
'user_added' => $userAdded,
'totals' => [
'expected' => \count($themeAccounts),
'missing' => \count($missing),
'present' => \count($present),
'renamed' => \count($renamed),
'user_added' => \count($userAdded),
],
];
}
/**
* Idempotently apply the active (or specified) theme to the DB.
*
* Never deletes, never renames user edits. For each theme node:
* - if a row matches by (theme_key, theme_account_code), stamp/refresh metadata
* - else if a row matches by (parent_id, name), back-stamp it
* - else create a new row
*
* @return array{created:int, stamped:int, skipped:int, theme_key:string}
*/
public static function apply(?string $key = null): array
{
$key = $key ?? self::current();
$tree = self::tree($key);
$stats = ['created' => 0, 'stamped' => 0, 'skipped' => 0, 'theme_key' => $key];
foreach ($tree as $node) {
self::applyNode($node, null, $key, $stats);
}
return $stats;
}
private static function applyNode(array $node, ?int $parentId, string $themeKey, array &$stats): void
{
$name = (string) ($node['name'] ?? '');
if ($name === '') {
return;
}
$code = self::accountCode($name);
$type = strtoupper((string) ($node['type'] ?? ''));
$defaultFlow = $node['default_flow']
?? (($type === 'REVENUE' || $type === 'LIABILITY') ? 'INCOME' : 'EXPENSE');
$account = Account::where('theme_key', $themeKey)
->where('theme_account_code', $code)
->where(function ($q) use ($parentId) {
if ($parentId === null) {
$q->whereNull('parent_id');
} else {
$q->where('parent_id', $parentId);
}
})
->first();
if (!$account) {
$account = Account::where('parent_id', $parentId)
->where('name', $name)
->first();
}
if ($account) {
$dirty = false;
if ($account->theme_key !== $themeKey) { $account->theme_key = $themeKey; $dirty = true; }
if ($account->theme_account_code !== $code) { $account->theme_account_code = $code; $dirty = true; }
if (empty($account->default_flow)) { $account->default_flow = $defaultFlow; $dirty = true; }
if (empty($account->description) && !empty($node['description'])) {
$account->description = $node['description'];
$dirty = true;
}
if ($dirty) {
$account->save();
$stats['stamped']++;
} else {
$stats['skipped']++;
}
} else {
$account = Account::create([
'hashkey' => Str::random(40),
'parent_id' => $parentId,
'type' => $type,
'default_flow' => $defaultFlow,
'name' => $name,
'description' => $node['description'] ?? null,
'theme_key' => $themeKey,
'theme_account_code' => $code,
'is_active' => true,
]);
$stats['created']++;
}
if (!empty($node['children']) && \is_array($node['children'])) {
foreach ($node['children'] as $child) {
self::applyNode($child, $account->id, $themeKey, $stats);
}
}
}
/**
* Lightweight summary of all themes for admin UI dropdowns.
*
* @return array<int, array<string, mixed>>
*/
public static function options(): array
{
$options = [];
foreach (self::all() as $key => $def) {
$options[] = [
'key' => $key,
'label' => $def['label'] ?? $key,
'description' => $def['description'] ?? null,
'version' => $def['version'] ?? 1,
];
}
return $options;
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Support;
class AppVersion
{
/**
* Get the current app asset version derived from the Vite manifest's last modified time.
*/
public static function get(): string
{
// Try manifest path
$manifestPath = BASE_PATH . '/public/build/.vite/manifest.json';
if (file_exists($manifestPath)) {
return (string) filemtime($manifestPath);
}
// Fallback for non-build environments
return '1';
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Support;
final class CdnAssetHelper
{
private static ?array $manifest = null;
private static function load(): array
{
if (self::$manifest === null) {
$path = BASE_PATH . '/resources/cdn-manifest.json';
$raw = @file_get_contents($path);
if ($raw === false) {
self::$manifest = ['assets' => []];
} else {
$decoded = json_decode($raw, true);
self::$manifest = is_array($decoded) ? $decoded : ['assets' => []];
}
}
return self::$manifest;
}
public static function url(string $logicalName): string
{
$base = (string) config('cdn.base');
$manifest = self::load();
$path = $manifest['assets'][$logicalName] ?? null;
if ($path === null) {
return $base . '/missing/' . ltrim($logicalName, '/');
}
return $base . '/' . ltrim($path, '/');
}
public static function base(): string
{
return (string) config('cdn.base');
}
public static function manifestForJs(): array
{
return self::load()['assets'] ?? [];
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Models\PersonalAccessToken;
use Hyperf\Database\Model\Relations\MorphMany;
trait HasApiTokens
{
public function tokens(): MorphMany
{
return $this->morphMany(PersonalAccessToken::class, 'tokenable');
}
public function currentAccessToken(): ?PersonalAccessToken
{
return $this->accessToken ?? null;
}
public function withAccessToken(?PersonalAccessToken $token): static
{
$this->accessToken = $token;
return $this;
}
/**
* Create a new token.
*
* @param array<int, string> $abilities
* @param array<int, string> $allowedIps
* @return array{token: PersonalAccessToken, plainTextToken: string}
*/
public function createToken(
string $name,
array $abilities,
?array $allowedIps = null,
?\DateTimeInterface $expiresAt = null,
?string $description = null,
?int $createdBy = null,
): array {
foreach ($abilities as $ability) {
if (! TokenAbilities::exists($ability)) {
throw new \InvalidArgumentException("Unknown ability: {$ability}");
}
}
$plain = bin2hex(random_bytes(32));
$token = $this->tokens()->create([
'name' => $name,
'description' => $description,
'token' => hash('sha256', $plain),
'abilities' => array_values(array_unique($abilities)),
'allowed_ips' => $allowedIps ? array_values(array_filter(array_map('trim', $allowedIps))) : null,
'expires_at' => $expiresAt,
'created_by' => $createdBy,
]);
return [
'token' => $token,
'plainTextToken' => $token->id . '|' . $plain,
];
}
}

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Support;
use Hypervel\Codec\Json;
use App\Models\User;
use App\Models\Market\Product;
use App\Models\Market\Store;
/**
* HashkeyResolver provides a universal resolver for any model by hashkey.
* This allows resolving resources (User, Product, Store, etc.) without knowing
* the specific model class upfront.
*/
class HashkeyResolver
{
/**
* Resolve a resource by its hashkey.
* This is a generic method that works with any model class.
*
* @param string $hashkey The hashkey to resolve
* @param string $modelClass The model class name (e.g., 'App\Models\User')
* @return mixed|null The resolved model instance or null if not found
*/
public function resolveByHashkey($hashkey, $modelClass)
{
if (!$hashkey || empty($modelClass)) {
return null;
}
// Use the model's static where method to find by hashkey
try {
return $modelClass::where('hashkey', $hashkey)->first();
} catch (\Exception $e) {
error_log("[HashkeyResolver] Error resolving {$modelClass} by hashkey: " . $e->getMessage());
return null;
}
}
/**
* Resolve a user by their hashkey.
*
* @param string $hashkey The user's hashkey
* @return mixed|null The resolved User instance or null if not found
*/
public function resolveUserByHashkey($hashkey)
{
return $this->resolveByHashkey($hashkey, User::class);
}
/**
* Resolve a product by its hashkey.
*
* @param string $hashkey The product's hashkey
* @return mixed|null The resolved Product instance or null if not found
*/
public function resolveProductByHashkey($hashkey)
{
return $this->resolveByHashkey($hashkey, Product::class);
}
/**
* Resolve a store by its hashkey.
*
* @param string $hashkey The store's hashkey
* @return mixed|null The resolved Store instance or null if not found
*/
public function resolveStoreByHashkey($hashkey)
{
return $this->resolveByHashkey($hashkey, Store::class);
}
/**
* Check if a model exists by its hashkey.
*
* @param string $hashkey The resource's hashkey
* @param string $modelClass The model class name
* @return bool True if the resource exists, false otherwise
*/
public function existsByHashkey($hashkey, $modelClass)
{
if (!$hashkey || empty($modelClass)) {
return false;
}
try {
return $modelClass::where('hashkey', $hashkey)->exists();
} catch (\Exception $e) {
error_log("[HashkeyResolver] Error checking existence for {$modelClass}: " . $e->getMessage());
return false;
}
}
/**
* Resolve multiple resources by their hashkeys.
*
* @param array $hashkeys Array of hashkeys to resolve
* @param string $modelClass The model class name
* @return array Collection of resolved models
*/
public function resolveMultipleByHashkey($hashkeys, $modelClass)
{
if (!is_array($hashkeys) || empty($hashkeys)) {
return [];
}
try {
return $modelClass::where('hashkey', '!=', null)
->whereIn('hashkey', $hashkeys)
->get();
} catch (\Exception $e) {
error_log("[HashkeyResolver] Error resolving multiple by hashkey: " . $e->getMessage());
return [];
}
}
/**
* Get hashkey from request input (from various sources).
* This helper method looks for 'hashkey' or 'target' in the request.
*
* @param \Hypervel\Http\Request $request The request object
* @return string|null The hashkey value or null
*/
public function getHashkeyFromRequest($request)
{
$hashkey = $request->input('hashkey') ?: $request->input('target');
return !empty($hashkey) ? $hashkey : null;
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Support;
class IslandGroupHelper
{
public const LUZON = 'luzon';
public const VISAYAS = 'visayas';
public const MINDANAO = 'mindanao';
/**
* Map of region location_key (lowercased) → island group key.
* Covers common name variants (e.g., "ncr" / "national capital region").
*/
private const REGION_TO_ISLAND = [
// Luzon
'ncr' => self::LUZON,
'national capital region' => self::LUZON,
'metro manila' => self::LUZON,
'car' => self::LUZON,
'cordillera administrative region' => self::LUZON,
'region i' => self::LUZON,
'region 1' => self::LUZON,
'ilocos region' => self::LUZON,
'region ii' => self::LUZON,
'region 2' => self::LUZON,
'cagayan valley' => self::LUZON,
'region iii' => self::LUZON,
'region 3' => self::LUZON,
'central luzon' => self::LUZON,
'region iv-a' => self::LUZON,
'region 4-a' => self::LUZON,
'region iva' => self::LUZON,
'calabarzon' => self::LUZON,
'region iv-b' => self::LUZON,
'region 4-b' => self::LUZON,
'region ivb' => self::LUZON,
'mimaropa' => self::LUZON,
'mimaropa region' => self::LUZON,
'region v' => self::LUZON,
'region 5' => self::LUZON,
'bicol region' => self::LUZON,
// Visayas
'region vi' => self::VISAYAS,
'region 6' => self::VISAYAS,
'western visayas' => self::VISAYAS,
'region vii' => self::VISAYAS,
'region 7' => self::VISAYAS,
'central visayas' => self::VISAYAS,
'region viii' => self::VISAYAS,
'region 8' => self::VISAYAS,
'eastern visayas' => self::VISAYAS,
// Mindanao
'region ix' => self::MINDANAO,
'region 9' => self::MINDANAO,
'zamboanga peninsula' => self::MINDANAO,
'region x' => self::MINDANAO,
'region 10' => self::MINDANAO,
'northern mindanao' => self::MINDANAO,
'region xi' => self::MINDANAO,
'region 11' => self::MINDANAO,
'davao region' => self::MINDANAO,
'region xii' => self::MINDANAO,
'region 12' => self::MINDANAO,
'soccsksargen' => self::MINDANAO,
'region xiii' => self::MINDANAO,
'region 13' => self::MINDANAO,
'caraga' => self::MINDANAO,
'barmm' => self::MINDANAO,
'bangsamoro' => self::MINDANAO,
'bangsamoro autonomous region in muslim mindanao' => self::MINDANAO,
'armm' => self::MINDANAO,
];
private const ISLAND_LABELS = [
self::LUZON => 'Luzon',
self::VISAYAS => 'Visayas',
self::MINDANAO => 'Mindanao',
];
private const ISLAND_CENTERS = [
self::LUZON => ['lat' => 16.5, 'lng' => 121.0],
self::VISAYAS => ['lat' => 11.0, 'lng' => 123.5],
self::MINDANAO => ['lat' => 7.5, 'lng' => 124.5],
];
public static function islands(): array
{
return [self::LUZON, self::VISAYAS, self::MINDANAO];
}
public static function label(string $island): string
{
return self::ISLAND_LABELS[$island] ?? ucfirst($island);
}
public static function center(string $island): array
{
return self::ISLAND_CENTERS[$island] ?? ['lat' => 12.0, 'lng' => 122.5];
}
/**
* Resolve an island group from a region's location_key or name.
* Returns null if it can't be matched (caller should bucket as "unassigned").
*/
public static function fromRegion(?string $regionKeyOrName): ?string
{
if (!$regionKeyOrName) return null;
$key = strtolower(trim($regionKeyOrName));
if (isset(self::REGION_TO_ISLAND[$key])) {
return self::REGION_TO_ISLAND[$key];
}
// Loose substring match for entries like "Region IV-A (CALABARZON)"
foreach (self::REGION_TO_ISLAND as $needle => $island) {
if (str_contains($key, $needle)) return $island;
}
return null;
}
}

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Models\SystemSetting;
use Hypervel\Support\Facades\Config;
use Throwable;
/**
* Helper for checking module enable/disable status.
*
* Resolution order (highest priority first):
* 1. SystemSetting override (key: "module_states_override", JSON map)
* 2. config/modules.php (env-driven defaults)
*
* The DB-backed override allows the Ultimate Console to toggle modules at
* runtime without redeploying / changing env vars.
*/
class ModuleHelper
{
/**
* SystemSetting key that holds the per-module override map.
*/
public const OVERRIDE_KEY = 'module_states_override';
public static function isSystemEnabled(): bool
{
return (bool) Config::get('modules.system_enabled', true);
}
public static function isEnabled(string $moduleKey): bool
{
if (!static::isSystemEnabled()) {
return true;
}
$overrides = static::getOverrides();
if (array_key_exists($moduleKey, $overrides)) {
return (bool) $overrides[$moduleKey];
}
return (bool) Config::get("modules.{$moduleKey}.enabled", true);
}
public static function isDisabled(string $moduleKey): bool
{
return !static::isEnabled($moduleKey);
}
public static function getLabel(string $moduleKey): string
{
return Config::get("modules.{$moduleKey}.label", ucfirst($moduleKey));
}
/**
* Get all module statuses including the effective enabled state, the
* config/env default, and the override value (if any).
*
* Returns: ['key' => [
* 'enabled' => bool, // effective state
* 'config_default' => bool, // env / config value
* 'override' => bool|null, // SystemSetting override (null if none)
* 'label' => string,
* 'description' => string,
* ], ...]
*/
public static function getAllModules(): array
{
$modules = Config::get('modules', []);
$overrides = static::getOverrides();
$result = [];
foreach ($modules as $key => $config) {
if (!is_array($config)) {
// Skips scalar entries like 'system_enabled'.
continue;
}
$configDefault = (bool) ($config['enabled'] ?? true);
$hasOverride = array_key_exists($key, $overrides);
$override = $hasOverride ? (bool) $overrides[$key] : null;
$result[$key] = [
'enabled' => $hasOverride ? $override : $configDefault,
'config_default' => $configDefault,
'override' => $override,
'label' => $config['label'] ?? ucfirst($key),
'description' => $config['description'] ?? '',
];
}
return $result;
}
/**
* Get only the effective enabled/disabled states.
*
* @return array<string, bool>
*/
public static function getModuleStates(): array
{
$states = [];
foreach (static::getAllModules() as $key => $info) {
$states[$key] = (bool) $info['enabled'];
}
return $states;
}
/**
* Read the override map from SystemSetting (cached).
*
* @return array<string, bool>
*/
protected static function getOverrides(): array
{
try {
$value = SystemSetting::getValue(static::OVERRIDE_KEY);
} catch (Throwable $e) {
return [];
}
if (is_string($value) && $value !== '') {
$decoded = json_decode($value, true);
$value = is_array($decoded) ? $decoded : [];
}
if (!is_array($value)) {
return [];
}
$out = [];
foreach ($value as $k => $v) {
$out[(string) $k] = filter_var($v, FILTER_VALIDATE_BOOLEAN);
}
return $out;
}
}

View File

@@ -0,0 +1,145 @@
<?php
namespace App\Support;
class PaletteExtractor
{
/**
* Extract a usable brand palette from an image file.
* Returns ['primary' => '#rrggbb', 'accent' => '#rrggbb', 'tint' => '#rrggbb']
* or null if extraction fails (e.g. GD missing, unreadable file).
*/
public static function extract(string $path): ?array
{
if (!extension_loaded('gd') || !is_readable($path)) {
return null;
}
$info = @getimagesize($path);
if (!$info) {
return null;
}
$img = match ($info[2]) {
IMAGETYPE_JPEG => @imagecreatefromjpeg($path),
IMAGETYPE_PNG => @imagecreatefrompng($path),
IMAGETYPE_GIF => @imagecreatefromgif($path),
IMAGETYPE_WEBP => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($path) : null,
default => null,
};
if (!$img) {
return null;
}
$w = imagesx($img);
$h = imagesy($img);
$stepX = max(1, (int) floor($w / 60));
$stepY = max(1, (int) floor($h / 60));
$buckets = [];
for ($y = 0; $y < $h; $y += $stepY) {
for ($x = 0; $x < $w; $x += $stepX) {
$rgba = imagecolorat($img, $x, $y);
$a = ($rgba >> 24) & 0x7F;
if ($a > 90) {
continue; // mostly transparent
}
$r = ($rgba >> 16) & 0xFF;
$g = ($rgba >> 8) & 0xFF;
$b = $rgba & 0xFF;
$sum = $r + $g + $b;
if ($sum < 60 || $sum > 720) {
continue; // too dark or too light
}
$max = max($r, $g, $b);
$min = min($r, $g, $b);
if (($max - $min) < 25) {
continue; // too gray
}
// quantize to 32-step buckets
$key = (($r >> 5) << 10) | (($g >> 5) << 5) | ($b >> 5);
if (!isset($buckets[$key])) {
$buckets[$key] = ['r' => 0, 'g' => 0, 'b' => 0, 'n' => 0];
}
$buckets[$key]['r'] += $r;
$buckets[$key]['g'] += $g;
$buckets[$key]['b'] += $b;
$buckets[$key]['n']++;
}
}
imagedestroy($img);
if (empty($buckets)) {
return null;
}
uasort($buckets, fn ($a, $b) => $b['n'] <=> $a['n']);
$candidates = [];
foreach ($buckets as $bucket) {
$candidates[] = [
'r' => (int) round($bucket['r'] / $bucket['n']),
'g' => (int) round($bucket['g'] / $bucket['n']),
'b' => (int) round($bucket['b'] / $bucket['n']),
'n' => $bucket['n'],
];
if (count($candidates) >= 8) {
break;
}
}
$primary = $candidates[0];
$accent = null;
foreach (array_slice($candidates, 1) as $c) {
$dr = $c['r'] - $primary['r'];
$dg = $c['g'] - $primary['g'];
$db = $c['b'] - $primary['b'];
if (sqrt($dr * $dr + $dg * $dg + $db * $db) > 60) {
$accent = $c;
break;
}
}
if (!$accent) {
$accent = self::shift($primary, 30);
}
$tint = self::mixWithWhite($primary, 0.92);
return [
'primary' => self::toHex($primary),
'accent' => self::toHex($accent),
'tint' => self::toHex($tint),
];
}
private static function toHex(array $c): string
{
return sprintf('#%02x%02x%02x',
max(0, min(255, $c['r'])),
max(0, min(255, $c['g'])),
max(0, min(255, $c['b']))
);
}
private static function shift(array $c, int $delta): array
{
return [
'r' => ($c['r'] + $delta) % 256,
'g' => ($c['g'] + $delta * 2) % 256,
'b' => ($c['b'] + $delta * 3) % 256,
];
}
private static function mixWithWhite(array $c, float $whiteRatio): array
{
$w = max(0.0, min(1.0, $whiteRatio));
return [
'r' => (int) round($c['r'] * (1 - $w) + 255 * $w),
'g' => (int) round($c['g'] * (1 - $w) + 255 * $w),
'b' => (int) round($c['b'] * (1 - $w) + 255 * $w),
];
}
}

View File

@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace App\Support;
/**
* RouteArgumentParser handles parsing URL arguments in format: "page-name--h:HASHKEY" or "page-name--e:ENCODED_PAYLOAD"
*/
class RouteArgumentParser
{
/**
* Parse a route argument string into its components
*
* @param string $argument The route argument string (e.g., "edituser--h:USER_HASH123")
* @return array {slug: string, type?: 'hash'|'payload', value?: string} Array with parsed components
*/
public function parseArgument($argument)
{
if (!is_string($argument) || empty($argument)) {
return [
'slug' => $argument,
'type' => null,
'value' => null
];
}
// Check for hash format: --h:HASHKEY
if (preg_match('/^(.*?)--h:(.*)$/', $argument, $matches)) {
return [
'slug' => $matches[1],
'type' => 'hash',
'value' => $this->decodeHashValue($matches[2])
];
}
// Check for payload format: --e:ENCODED_PAYLOAD
if (preg_match('/^(.*?)--e:(.*)$/', $argument, $matches)) {
return [
'slug' => $matches[1],
'type' => 'payload',
'value' => $this->decodePayloadValue($matches[2])
];
}
// Backward compatibility: Check for hash format without colon: --hHASHKEY
if (preg_match('/^(.*?)--h([^:].*)$/', $argument, $matches)) {
return [
'slug' => $matches[1],
'type' => 'hash',
'value' => $this->decodeHashValue($matches[2])
];
}
// No hash/payload found - just return the slug
return [
'slug' => $argument,
'type' => null,
'value' => null
];
}
/**
* Decode a hash value from URL format (base64 encoded)
*/
private function decodeHashValue($encodedValue)
{
if (!is_string($encodedValue) || empty($encodedValue)) {
return $encodedValue;
}
try {
// Remove base64 encoding and decode
$decoded = base64_decode($encodedValue, true);
// If it's valid UTF-8 string representation of the hashkey
if (is_string($decoded) && mb_check_encoding($decoded, 'utf-8')) {
// Frontend encodes as btoa(encodeURIComponent(hashkey)),
// so we need to urldecode after base64_decode
return urldecode($decoded);
}
return $encodedValue; // Return original if decoding fails
} catch (\Exception $e) {
error_log('[RouteArgumentParser] Error decoding hash: ' . $e->getMessage());
return null;
}
}
/**
* Decode a payload value from URL format (base64 encoded JSON)
*/
private function decodePayloadValue($encodedValue)
{
if (!is_string($encodedValue) || empty($encodedValue)) {
return null;
}
try {
// Remove base64 encoding and decode
$decoded = base64_decode($encodedValue, true);
// Decode URI components if it was encoded on frontend
if ($decoded) {
$decoded = urldecode($decoded);
}
// Parse JSON
$jsonData = json_decode($decoded, true);
if (json_last_error() === JSON_ERROR_NONE) {
return $jsonData;
}
return null;
} catch (\Exception $e) {
error_log('[RouteArgumentParser] Error decoding payload: ' . $e->getMessage());
return null;
}
}
/**
* Check if argument contains hash format
*/
public function isHashFormat($argument)
{
return (bool) preg_match('/^(.*?)--h:?(.*)$/', $argument, $matches);
}
/**
* Check if argument contains payload format
*/
public function isPayloadFormat($argument)
{
return (bool) preg_match('/^(.*?)--e:(.*)$/', $argument, $matches);
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace App\Support;
use App\Models\SystemSetting;
use App\Http\Controllers\FilesMainController;
use App\Support\AccountingTheme;
class SystemSettingsHelper
{
/**
* Get application name.
*/
public static function appName(): string
{
return SystemSetting::getValue('app_name', 'BukidBounty');
}
/**
* Get application description.
*/
public static function appDescription(): string
{
return SystemSetting::getValue('app_description', 'Agricultural Management Platform & Marketplace');
}
/**
* Get application tagline.
*/
public static function appTagline(): string
{
return SystemSetting::getValue('app_tagline', 'Bounty of the Fields at Your Fingertips');
}
/**
* Get logo URL.
*/
public static function logoUrl(): string
{
$hashkey = SystemSetting::getValue('app_logo');
if ($hashkey) {
return FilesMainController::generateURLforFileListHash($hashkey);
}
return cdn_asset('vendor/assets/icons/192x192.png');
}
/**
* Get primary theme color.
*/
public static function primaryColor(): string
{
return SystemSetting::getValue('primary_color', '#0d6efd');
}
/**
* Get footer text.
*/
public static function footerText(): string
{
return SystemSetting::getValue('footer_text', '&copy; 2026 BukidBounty Ecosystem. All rights reserved.');
}
/**
* Check if maintenance mode is active.
*/
public static function isMaintenanceMode(): bool
{
return SystemSetting::getValue('maintenance_mode', false);
}
/**
* Get the application mode (corporate, cooperative, ngo, others).
*/
public static function appMode(): string
{
return SystemSetting::getValue('app_mode', 'corporate');
}
/**
* Get the hashkey of the main cooperative/organization (if any).
*/
public static function mainOrganizationHashkey(): ?string
{
$value = SystemSetting::getValue('main_organization');
return $value ?: null;
}
/**
* Get the main cooperative/organization model (if any).
*/
public static function mainOrganization(): ?\App\Models\Market\Organization
{
$hashkey = static::mainOrganizationHashkey();
if (!$hashkey) {
return null;
}
return \App\Models\Market\Organization::where('hashkey', $hashkey)->first();
}
/**
* Get the active accounting theme key.
*/
public static function accountingTheme(): string
{
return SystemSetting::getValue('accounting_theme', AccountingTheme::DEFAULT_KEY);
}
/**
* Get available chapter/org position titles.
*/
public static function chapterPositions(): array
{
$value = SystemSetting::getValue('chapter_positions');
if (!$value) {
return ['National Director', 'Regional Director', 'Provincial Coordinator', 'City/Municipal Officer', 'Barangay Captain', 'Secretary', 'Treasurer', 'Auditor', 'Member'];
}
return is_string($value) ? (json_decode($value, true) ?? []) : (array) $value;
}
}

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Support;
class TokenAbilities
{
public const WILDCARD = '*';
/**
* Catalog of all valid abilities grouped for UI presentation.
* @return array<string, array<int, array{key: string, label: string}>>
*/
public static function catalog(): array
{
return [
'Identity' => self::group([
'users:read', 'users:write', 'users:delete', 'users:impersonate',
'chapters:read', 'chapters:write', 'chapters:delete',
'chapter-members:read', 'chapter-members:write', 'chapter-members:delete',
'announcements:read', 'announcements:write', 'announcements:delete',
]),
'Content' => self::group([
'pages:read', 'pages:write', 'pages:delete',
'landing-pages:read', 'landing-pages:write', 'landing-pages:delete',
'page-memory:read', 'page-memory:write',
]),
'Files' => self::group([
'files:read', 'files:upload', 'files:delete', 'files:publish',
]),
'System' => self::group([
'system-settings:read', 'system-settings:write',
'db-backups:read', 'db-backups:write', 'db-backups:restore',
'table-logs:read',
'global-transactions:read', 'global-transactions:write',
]),
'Accounting' => self::group([
'accounts:read', 'accounts:write', 'accounts:delete',
'account-transactions:read', 'account-transactions:write', 'account-transactions:void',
'member-ledgers:read', 'member-ledgers:write',
]),
'Cooperatives' => self::group([
'cooperatives:read', 'cooperatives:write', 'cooperatives:delete',
'cooperative-members:read', 'cooperative-members:write', 'cooperative-members:delete',
'cooperative-documents:read', 'cooperative-documents:write', 'cooperative-documents:delete',
'cooperative-resolutions:read', 'cooperative-resolutions:write', 'cooperative-resolutions:delete',
'cooperative-votes:read', 'cooperative-votes:write',
'main-organization:read', 'main-organization:write',
]),
'Market' => self::group([
'products:read', 'products:write', 'products:delete',
'product-transactions:read', 'product-transactions:write', 'product-transactions:void',
'stores:read', 'stores:write', 'stores:delete',
'store-managers:read', 'store-managers:write',
'customers:read', 'customers:write', 'customers:delete',
'farmers:read', 'farmers:write', 'farmers:delete',
'couriers:read', 'couriers:write',
'shipments:read', 'shipments:write',
'carts:read', 'carts:write',
]),
'POS' => self::group([
'pos-sessions:read', 'pos-sessions:write', 'pos-sessions:close',
'pos-transactions:read', 'pos-transactions:write', 'pos-transactions:void',
'pos-access-keys:read', 'pos-access-keys:write', 'pos-access-keys:delete',
]),
'Property' => self::group([
'properties:read', 'properties:write', 'properties:delete',
'referrals:read', 'referrals:write',
'referral-keys:read', 'referral-keys:write', 'referral-keys:delete',
]),
];
}
/** @return array<int, string> */
public static function all(): array
{
$out = [];
foreach (self::catalog() as $group) {
foreach ($group as $entry) {
$out[] = $entry['key'];
}
}
return $out;
}
public static function exists(string $ability): bool
{
return $ability === self::WILDCARD || in_array($ability, self::all(), true);
}
/**
* @param array<int, string> $keys
* @return array<int, array{key: string, label: string}>
*/
private static function group(array $keys): array
{
return array_map(static fn ($k) => ['key' => $k, 'label' => $k], $keys);
}
}