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,131 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Helpers\ResponseHelper;
use App\Models\PersonalAccessToken;
use App\Models\User;
use App\Support\TokenAbilities;
use Hypervel\Http\Request;
use Hypervel\Support\Facades\Auth;
class ApiTokenController
{
public function catalog()
{
return response()->json([
'success' => true,
'data' => [
'abilities' => TokenAbilities::catalog(),
'wildcard' => TokenAbilities::WILDCARD,
],
]);
}
public function index(Request $request)
{
$tokens = PersonalAccessToken::query()
->where('tokenable_type', User::class)
->orderByDesc('id')
->get()
->map(fn ($t) => $this->present($t));
return response()->json(['success' => true, 'data' => $tokens]);
}
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:120',
'description' => 'nullable|string|max:500',
'abilities' => 'required|array|min:1',
'abilities.*' => 'string',
'allowed_ips' => 'nullable|array',
'allowed_ips.*' => 'string',
'expires_at' => 'nullable|date',
'tokenable_user_id' => 'nullable|integer',
]);
foreach ($validated['abilities'] as $ability) {
if (! TokenAbilities::exists($ability)) {
return ResponseHelper::returnError("Unknown ability: {$ability}", 422);
}
}
$owner = Auth::user();
$tokenable = $owner;
if (! empty($validated['tokenable_user_id'])) {
$tokenable = User::query()->find($validated['tokenable_user_id']);
if (! $tokenable) {
return ResponseHelper::returnError('Target user not found.', 404);
}
}
$result = $tokenable->createToken(
name: $validated['name'],
abilities: $validated['abilities'],
allowedIps: $validated['allowed_ips'] ?? null,
expiresAt: !empty($validated['expires_at']) ? new \DateTimeImmutable($validated['expires_at']) : null,
description: $validated['description'] ?? null,
createdBy: $owner->id,
);
return response()->json([
'success' => true,
'data' => [
'token' => $this->present($result['token']),
'plain_text_token' => $result['plainTextToken'],
],
'message' => 'Token created. Copy it now — it will not be shown again.',
]);
}
public function revoke(Request $request, int $id)
{
$token = PersonalAccessToken::query()->find($id);
if (! $token) {
return ResponseHelper::returnError('Token not found.', 404);
}
if ($token->revoked_at !== null) {
return ResponseHelper::returnError('Token already revoked.', 422);
}
$token->forceFill([
'revoked_at' => now(),
'revoked_by' => Auth::id(),
])->save();
return response()->json(['success' => true, 'data' => $this->present($token)]);
}
public function destroy(Request $request, int $id)
{
$token = PersonalAccessToken::query()->find($id);
if (! $token) {
return ResponseHelper::returnError('Token not found.', 404);
}
$token->delete();
return response()->json(['success' => true]);
}
private function present(PersonalAccessToken $t): array
{
return [
'id' => $t->id,
'name' => $t->name,
'description' => $t->description,
'tokenable_id' => $t->tokenable_id,
'tokenable_type' => $t->tokenable_type,
'abilities' => $t->abilities ?? [],
'allowed_ips' => $t->allowed_ips ?? [],
'expires_at' => $t->expires_at,
'last_used_at' => $t->last_used_at,
'last_used_ip' => $t->last_used_ip,
'revoked_at' => $t->revoked_at,
'created_by' => $t->created_by,
'created_at' => $t->created_at,
'is_active' => $t->isActive(),
];
}
}

View File

@@ -0,0 +1,268 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Helpers\ResponseHelper;
use App\Models\LandingPage;
use App\Enums\UserTypes;
use Hypervel\Http\Request;
use Hypervel\Support\Facades\Auth;
use Hypervel\Support\Facades\Response;
use Exception;
class LandingPageController
{
/**
* Allowed roles for landing page management.
*/
private static array $allowedTypes = [
'ult',
'super operator',
'coordinator',
];
/**
* Check if the current user is authorized.
*/
private function isAuthorized(): bool
{
$user = Auth::user();
if (!$user) return false;
$type = $user->acct_type;
if ($type instanceof UserTypes) {
$type = $type->value;
}
return in_array($type, self::$allowedTypes);
}
/**
* List all landing pages.
*/
public function index(Request $request)
{
if (!$this->isAuthorized()) {
return ResponseHelper::returnUnauthorized();
}
$pages = LandingPage::orderByDesc('is_active')
->orderByDesc('updated_at')
->get();
return response()->json([
'success' => true,
'data' => $pages,
]);
}
/**
* Get a single landing page by hashkey.
*/
public function show(Request $request)
{
if (!$this->isAuthorized()) {
return ResponseHelper::returnUnauthorized();
}
$hashkey = $request->input('hashkey');
if (!$hashkey) {
return response()->json(['success' => false, 'message' => 'Hashkey is required'], 400);
}
$page = LandingPage::where('hashkey', $hashkey)->first();
if (!$page) {
return response()->json(['success' => false, 'message' => 'Landing page not found'], 404);
}
return response()->json([
'success' => true,
'data' => $page,
]);
}
/**
* Create or update a landing page.
*/
public function store(Request $request)
{
if (!$this->isAuthorized()) {
return ResponseHelper::returnUnauthorized();
}
$validated = $request->validate([
'title' => 'required|string|max:255',
'html_content' => 'required|string',
'description' => 'nullable|string|max:1000',
'hashkey' => 'nullable|string', // If provided, it's an update
]);
try {
$hashkey = $validated['hashkey'] ?? null;
if ($hashkey) {
// Update existing
$page = LandingPage::where('hashkey', $hashkey)->first();
if (!$page) {
return response()->json(['success' => false, 'message' => 'Landing page not found'], 404);
}
$page->title = $validated['title'];
$page->html_content = $validated['html_content'];
$page->description = $validated['description'] ?? $page->description;
$page->save();
return response()->json([
'success' => true,
'message' => 'Landing page updated successfully',
'data' => $page,
]);
} else {
// Create new
$page = LandingPage::create([
'title' => $validated['title'],
'html_content' => $validated['html_content'],
'description' => $validated['description'] ?? null,
'is_active' => false,
]);
return response()->json([
'success' => true,
'message' => 'Landing page created successfully',
'data' => $page,
]);
}
} catch (Exception $e) {
return response()->json([
'success' => false,
'message' => 'Failed to save landing page: ' . $e->getMessage(),
], 500);
}
}
/**
* Set a landing page as active.
*/
public function setActive(Request $request)
{
if (!$this->isAuthorized()) {
return ResponseHelper::returnUnauthorized();
}
$hashkey = $request->input('hashkey');
if (!$hashkey) {
return response()->json(['success' => false, 'message' => 'Hashkey is required'], 400);
}
try {
$page = LandingPage::where('hashkey', $hashkey)->first();
if (!$page) {
return response()->json(['success' => false, 'message' => 'Landing page not found'], 404);
}
$page->setAsActive();
return response()->json([
'success' => true,
'message' => 'Landing page activated successfully',
'data' => $page,
]);
} catch (Exception $e) {
return response()->json([
'success' => false,
'message' => 'Failed to activate landing page: ' . $e->getMessage(),
], 500);
}
}
/**
* Deactivate all landing pages (show default homepage for guests).
*/
public function deactivateAll(Request $request)
{
if (!$this->isAuthorized()) {
return ResponseHelper::returnUnauthorized();
}
try {
LandingPage::where('is_active', true)->update(['is_active' => false]);
return response()->json([
'success' => true,
'message' => 'All landing pages deactivated. Default homepage will be shown.',
]);
} catch (Exception $e) {
return response()->json([
'success' => false,
'message' => 'Failed to deactivate: ' . $e->getMessage(),
], 500);
}
}
/**
* Delete a landing page.
*/
public function destroy(Request $request)
{
if (!$this->isAuthorized()) {
return ResponseHelper::returnUnauthorized();
}
$hashkey = $request->input('hashkey');
if (!$hashkey) {
return response()->json(['success' => false, 'message' => 'Hashkey is required'], 400);
}
try {
$page = LandingPage::where('hashkey', $hashkey)->first();
if (!$page) {
return response()->json(['success' => false, 'message' => 'Landing page not found'], 404);
}
$page->delete();
return response()->json([
'success' => true,
'message' => 'Landing page deleted successfully',
]);
} catch (Exception $e) {
return response()->json([
'success' => false,
'message' => 'Failed to delete landing page: ' . $e->getMessage(),
], 500);
}
}
/**
* Public endpoint: Get the currently active landing page content.
* No authentication required - used to show landing page to guests.
*/
public function getActiveLandingPage()
{
try {
$page = LandingPage::getActive();
} catch (\Throwable $e) {
return Response::json(['success' => false, 'data' => null, 'has_landing_page' => false]);
}
if (!$page) {
return Response::json([
'success' => true,
'data' => null,
'has_landing_page' => false,
]);
}
return Response::json([
'success' => true,
'data' => [
'title' => $page->title,
'html_content' => $page->html_content,
'description' => $page->description,
],
'has_landing_page' => true,
]);
}
}

View File

@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Helpers\ResponseHelper;
use App\Models\Market\PosAccessKey;
use App\Models\Market\Store;
use App\Models\User;
use Hypervel\Http\Request;
use Hypervel\Support\Facades\Auth;
use Hyperf\Stringable\Str;
use App\Http\Controllers\Helpers\Permissions\UserPermissions;
use App\Enums\UserActions;
class PosAccessKeyController
{
/**
* List POS access keys. Auto-expires any past-due keys first.
* - Ultimate/super operator/operator: see all keys
* - Store owner/manager: see keys for stores they own or manage
*/
public function index(Request $request)
{
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ViewPosAccessKeys)) {
return ResponseHelper::returnUnauthorized();
}
// Auto-deactivate any expired keys
PosAccessKey::autoExpire();
$user = Auth::user();
$acctType = $user->acct_type->value ?? $user->acct_type ?? '';
// Ultimate, Super Operator, and Operator see everything
if (in_array($acctType, ['ult', 'super operator', 'operator'])) {
$query = PosAccessKey::with(['store:id,name,hashkey,owner_id', 'store.owner:id,name,nickname,hashkey'])
->orderBy('id', 'desc');
} else {
// Non-ultimate users see their own and their descendants' stores' keys
$descendants = $user->getAllDescendants();
$descendantIds = $descendants->pluck('id')->push($user->id)->toArray();
$storeIds = Store::whereIn('owner_id', $descendantIds)
->orWhereIn('manager_id', $descendantIds)
->pluck('id')
->toArray();
$query = PosAccessKey::with(['store:id,name,hashkey,owner_id', 'store.owner:id,name,nickname,hashkey'])
->whereIn('store_id', $storeIds)
->orderBy('id', 'desc');
}
$keys = $query->get();
return ResponseHelper::returnSuccessResponse($keys, 'pos_access_keys');
}
public function store(Request $request)
{
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::CreatePosAccessKey)) {
return ResponseHelper::returnUnauthorized();
}
$validated = $request->validate([
'name' => 'required|string|max:255',
'store_hash' => 'required|string',
'expires_at' => 'nullable|string',
]);
$store = Store::where('hashkey', $validated['store_hash'])->first();
if (!$store) {
return ResponseHelper::returnError('Store not found', 404);
}
$user = Auth::user();
$acctType = $user->acct_type->value ?? $user->acct_type ?? '';
if (!in_array($acctType, ['ult', 'super operator', 'operator'])) {
$descendants = $user->getAllDescendants();
$allowedIds = $descendants->pluck('id')->push($user->id)->toArray();
if (!in_array($store->owner_id, $allowedIds) && !in_array($store->manager_id, $allowedIds)) {
return ResponseHelper::returnError('Unauthorized to create keys for this store', 403);
}
}
$data = [
'access_key' => 'PK-' . Str::upper(Str::random(16)),
'store_id' => $store->id,
'name' => $validated['name'],
'created_by' => Auth::id(),
'status' => 'active',
];
// Set expiry if provided
if (!empty($validated['expires_at'])) {
$data['expires_at'] = $validated['expires_at'];
}
$key = PosAccessKey::create($data);
return ResponseHelper::returnSuccessResponse($key, $key->hashkey, 'POS Access Key created');
}
public function destroy(Request $request)
{
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::DeletePosAccessKey)) {
return ResponseHelper::returnUnauthorized();
}
$hashkey = $request->input('target');
$key = PosAccessKey::with('store')->where('hashkey', $hashkey)->first();
if (!$key) {
return ResponseHelper::returnError('Key not found', 404);
}
if (!$this->userOwnsKeyStore($key)) {
return ResponseHelper::returnError('Unauthorized to delete this key', 403);
}
$key->delete();
return ResponseHelper::returnSuccessResponse([], $hashkey, 'Key deleted');
}
public function toggleStatus(Request $request)
{
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::TogglePosAccessKey)) {
return ResponseHelper::returnUnauthorized();
}
$hashkey = $request->input('target');
$key = PosAccessKey::with('store')->where('hashkey', $hashkey)->first();
if (!$key) {
return ResponseHelper::returnError('Key not found', 404);
}
if (!$this->userOwnsKeyStore($key)) {
return ResponseHelper::returnError('Unauthorized to modify this key', 403);
}
$key->status = $key->status === 'active' ? 'inactive' : 'active';
$key->save();
return ResponseHelper::returnSuccessResponse($key, $hashkey, 'Status updated');
}
/**
* Ownership gate shared by destroy/toggleStatus: Big 3 always pass;
* everyone else must own or manage (directly or via descendants) the
* store the key belongs to.
*/
private function userOwnsKeyStore(PosAccessKey $key): bool
{
$user = Auth::user();
if (!$user) {
return false;
}
$acctType = $user->acct_type->value ?? $user->acct_type ?? '';
if (in_array($acctType, ['ult', 'super operator', 'operator'])) {
return true;
}
$store = $key->store;
if (!$store) {
return false;
}
$allowedIds = $user->getAllDescendants()->pluck('id')->push($user->id)->toArray();
return in_array($store->owner_id, $allowedIds) || in_array($store->manager_id, $allowedIds);
}
}

View File

@@ -0,0 +1,284 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\FilesMainController;
use App\Http\Controllers\Helpers\ResponseHelper;
use App\Models\SystemSetting;
use App\Enums\UserTypes;
use Hypervel\Http\Request;
use Hypervel\Support\Facades\Auth;
use Exception;
class SystemSettingsController
{
/**
* Get all system settings grouped by group.
*/
public function index(Request $request)
{
if (!Auth::user()->isUltimate()) {
return ResponseHelper::returnUnauthorized();
}
$keys = $request->input('keys', []);
if (!empty($keys)) {
$settings = SystemSetting::whereIn('key', (array)$keys)->get();
return response()->json([
'success' => true,
'data' => $settings
]);
}
$settings = SystemSetting::all()->groupBy('group');
return response()->json([
'success' => true,
'data' => $settings
]);
}
/**
* Update multiple settings.
*/
public function update(Request $request)
{
if (!Auth::user()->isUltimate()) {
return ResponseHelper::returnUnauthorized();
}
$validated = $request->validate([
'settings' => 'required|array',
]);
try {
foreach ($validated['settings'] as $key => $value) {
SystemSetting::setValue($key, $value);
}
SystemSetting::clearCache();
return response()->json([
'success' => true,
'message' => 'System settings updated successfully'
]);
} catch (Exception $e) {
return response()->json([
'success' => false,
'message' => 'Failed to update settings: ' . $e->getMessage()
], 500);
}
}
/**
* Upload application logo.
*/
public function uploadLogo(Request $request)
{
if (!Auth::user()->isUltimate()) {
return ResponseHelper::returnUnauthorized();
}
if (!$request->hasFile('logo')) {
return response()->json(['success' => false, 'message' => 'No logo file provided'], 400);
}
try {
$file = $request->file('logo');
$filename = 'app_logo_' . time() . '.' . $file->getExtension();
// Extract palette from the uploaded file before it is moved by storage.
$palette = \App\Support\PaletteExtractor::extract($file->getRealPath());
// Reusing existing file storage system
$result = FilesMainController::uploadFileList(
$file,
'System Logo',
$filename,
'Application branding logo',
[],
'system',
[],
0,
'app_logo',
);
if ($result && isset($result->hashkey)) {
SystemSetting::setValue('app_logo', $result->hashkey);
if ($palette) {
SystemSetting::setValue('primary_color', $palette['primary']);
SystemSetting::setValue('accent_color', $palette['accent']);
SystemSetting::setValue('background_tint', $palette['tint']);
}
SystemSetting::clearCache();
return response()->json([
'success' => true,
'hashkey' => $result->hashkey,
'url' => FilesMainController::generateURLforFileListHash($result->hashkey),
'palette' => $palette,
'message' => 'Logo uploaded successfully'
]);
}
throw new Exception('File upload failed');
} catch (Exception $e) {
return response()->json([
'success' => false,
'message' => 'Logo upload failed: ' . $e->getMessage()
], 500);
}
}
/**
* Get public settings data.
*/
public static function getPublicSettingsData()
{
$publicKeys = ['app_name', 'app_logo', 'app_description', 'app_tagline', 'primary_color', 'accent_color', 'background_tint', 'footer_text', 'disabled_pages', 'top_up_enabled', 'app_mode', 'main_organization', 'accounting_theme', 'default_org_type', 'group_types', 'bible_verse_text', 'bible_verse_reference'];
$settings = [];
foreach ($publicKeys as $key) {
$settings[$key] = SystemSetting::getValue($key);
}
// Add module states
$settings['module_states'] = \App\Support\ModuleHelper::getModuleStates();
// Generate URL for logo if it exists
if (!empty($settings['app_logo'])) {
$settings['app_logo_url'] = FilesMainController::generateURLforFileListHash($settings['app_logo']);
} else {
$settings['app_logo_url'] = cdn_asset('vendor/assets/icons/192x192.png'); // Fallback to PWA icon on the CDN
}
// Resolve main organization hashkey to a usable data object
$settings['main_organization_data'] = null;
if (!empty($settings['main_organization'])) {
$org = \App\Models\Market\Organization::where('hashkey', $settings['main_organization'])->first();
if ($org) {
$settings['main_organization_data'] = [
'hashkey' => $org->hashkey,
'name' => $org->name,
'type' => $org->type,
];
}
}
return $settings;
}
/**
* List organizations available for selection as the main cooperative/organization.
*/
public function listOrganizations()
{
if (!Auth::user()->isUltimate()) {
return ResponseHelper::returnUnauthorized();
}
$orgs = \App\Models\Market\Organization::where('is_active', true)
->orderBy('type')
->orderBy('name')
->get(['hashkey', 'name', 'type']);
return response()->json([
'success' => true,
'data' => $orgs,
]);
}
/**
* Get all modules with their effective state, env/config default, and
* any DB-stored override. Used by the Ultimate Console module manager.
*/
public function getModules()
{
if (!Auth::user()->isUltimate()) {
return ResponseHelper::returnUnauthorized();
}
return response()->json([
'success' => true,
'data' => [
'system_enabled' => \App\Support\ModuleHelper::isSystemEnabled(),
'modules' => \App\Support\ModuleHelper::getAllModules(),
],
]);
}
/**
* Update the module override map. Accepts an associative array of
* { module_key: bool|null }. A null value clears that key's override
* so the env/config default takes effect again.
*/
public function updateModules(Request $request)
{
if (!Auth::user()->isUltimate()) {
return ResponseHelper::returnUnauthorized();
}
$validated = $request->validate([
'overrides' => 'required|array',
]);
$configModules = (array) \Hypervel\Support\Facades\Config::get('modules', []);
$current = SystemSetting::getValue(\App\Support\ModuleHelper::OVERRIDE_KEY);
if (is_string($current) && $current !== '') {
$decoded = json_decode($current, true);
$current = is_array($decoded) ? $decoded : [];
} elseif (!is_array($current)) {
$current = [];
}
foreach ($validated['overrides'] as $key => $value) {
// Only allow keys that are actually defined as modules.
if (!isset($configModules[$key]) || !is_array($configModules[$key])) {
continue;
}
if ($value === null || $value === 'null') {
unset($current[$key]);
} else {
$current[$key] = filter_var($value, FILTER_VALIDATE_BOOLEAN);
}
}
try {
SystemSetting::setValue(
\App\Support\ModuleHelper::OVERRIDE_KEY,
json_encode((object) $current),
'modules',
'json'
);
SystemSetting::clearCache();
return response()->json([
'success' => true,
'message' => 'Module states updated',
'data' => [
'modules' => \App\Support\ModuleHelper::getAllModules(),
],
]);
} catch (Exception $e) {
return response()->json([
'success' => false,
'message' => 'Failed to update modules: ' . $e->getMessage(),
], 500);
}
}
/**
* Get public settings for frontend.
*/
public function getPublicSettings()
{
return response()->json([
'success' => true,
'data' => self::getPublicSettingsData()
]);
}
}