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,55 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Market;
use App\Services\ActivityService;
use Hypervel\Http\Request;
use Hypervel\Support\Facades\Response;
use App\Http\Controllers\Helpers\ResponseHelper;
class ActivityController
{
/**
* Get recent activities.
*
* @param Request $request
* @return \Psr\Http\Message\ResponseInterface
*/
public function getRecent(Request $request)
{
$limit = (int) $request->input('limit', 10);
$service = new ActivityService();
$activities = $service->getRecentActivities($limit);
return Response::json([
'success' => true,
'data' => $activities
]);
}
/**
* Search activities.
*
* @param Request $request
* @return \Psr\Http\Message\ResponseInterface
*/
public function search(Request $request)
{
$query = $request->input('q', '');
$limit = (int) $request->input('limit', 20);
$service = new ActivityService();
if (empty($query)) {
$activities = $service->getRecentActivities($limit);
} else {
$activities = $service->searchActivities($query, $limit);
}
return Response::json([
'success' => true,
'data' => $activities
]);
}
}

View File

@@ -0,0 +1,577 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Market;
use App\Enums\UserActions;
use App\Enums\UserTypes;
use App\Http\Controllers\Helpers\Permissions\ProductPermissions;
use App\Http\Controllers\Helpers\Permissions\UserPermissions;
use App\Http\Controllers\Helpers\Permissions\UserTypeService;
use App\Http\Controllers\Helpers\ResponseHelper;
use App\Http\Controllers\Helpers\UserController;
use App\Models\Market\CooperativeMember;
use App\Models\Market\Organization;
use App\Models\Market\Product;
use App\Models\Market\Store;
use App\Models\User;
use Hypervel\Support\Str;
use Hypervel\Http\Request;
use Hypervel\Support\Facades\Auth;
use Hypervel\Support\Facades\DB;
use Hypervel\Support\Facades\Hash;
use Hypervel\Support\Facades\Response;
use Hypervel\Support\Facades\Validator;
class BatchController
{
/**
* Batch create products.
* Available for Ultimate, Super Operator, and Operator.
*/
public function batchCreateProducts(Request $request)
{
$user = Auth::user();
if (!$user) return ResponseHelper::returnUnauthorized();
$acctType = $user->acct_type instanceof UserTypes ? $user->acct_type : UserTypes::tryFrom($user->acct_type);
$isBig3 = in_array($acctType, [UserTypes::ULTIMATE, UserTypes::SUPER_OPERATOR, UserTypes::OPERATOR]);
$isStoreOwner = $acctType === UserTypes::STORE_OWNER;
if (!$isBig3 && !$isStoreOwner) {
return ResponseHelper::returnUnauthorized();
}
$products = $request->input('products', []);
if (empty($products)) {
return ResponseHelper::returnError('No products provided');
}
$targetStoreHash = $request->input('target_store_hash');
$targetStore = null;
if ($targetStoreHash) {
$targetStore = Store::where('hashkey', $targetStoreHash)->first();
if (!$targetStore) {
return ResponseHelper::returnError('Target store not found');
}
}
// STORE_OWNER must operate against a store they actually own/manage.
// Without that, batch-import has no destination and must be refused
// up-front so the UI doesn't silently 401 partway through.
if (!$isBig3) {
if (!$targetStore) {
return ResponseHelper::returnError('You must select one of your stores before importing products.', 422);
}
$ownsTarget = (int) $targetStore->owner_id === (int) $user->id
|| (int) $targetStore->manager_id === (int) $user->id
|| $targetStore->managers()->where('user_id', $user->id)->exists();
if (!$ownsTarget) {
return ResponseHelper::returnError('You can only import products into stores you own.', 403);
}
}
$results = [];
$errors = [];
DB::beginTransaction();
try {
foreach ($products as $index => $productData) {
$source = $productData['source'] ?? 'new';
if ($source === 'existing') {
if (!$targetStore) {
$errors[] = 'Row ' . ($index + 1) . ': A target store is required to import an existing product.';
continue;
}
$validator = Validator::make($productData, [
'product_hash' => 'required|string',
'price' => 'nullable|numeric|min:0',
'available' => 'nullable|numeric',
'description' => 'nullable|string',
]);
if ($validator->fails()) {
$errors[] = 'Row ' . ($index + 1) . ': ' . implode(', ', $validator->errors()->all());
continue;
}
$existing = Product::where('hashkey', $productData['product_hash'])->first();
if (!$existing) {
$errors[] = 'Row ' . ($index + 1) . ': Selected global product not found.';
continue;
}
$alreadyAttached = $targetStore->products()->where('prd_items.id', $existing->id)->exists();
if ($alreadyAttached) {
$errors[] = 'Row ' . ($index + 1) . ": '{$existing->name}' is already in the target store.";
continue;
}
$price = $productData['price'] ?? null;
$price = ($price === null || $price === '' || (float) $price <= 0)
? (float) $existing->price
: (float) $price;
$description = $productData['description'] ?? '';
if (trim((string) $description) === '') {
$description = (string) ($existing->description ?? '');
}
$available = (int) ($productData['available'] ?? 0);
$targetStore->products()->attach($existing->id, [
'available' => $available,
'price' => $price,
'description' => $description,
'is_active' => true,
]);
$results[] = $existing->hashkey;
continue;
}
$validator = Validator::make($productData, [
'name' => 'required|string|max:255',
'price' => 'required|numeric|min:0',
'available' => 'required|numeric',
'unitname' => 'required|string|max:100',
'description' => 'nullable|string',
'category' => 'nullable|string|max:255',
'subcategory' => 'nullable|string|max:255',
'barcode' => 'nullable|string|max:255',
'photourl' => 'nullable|array',
'photourl.*' => 'nullable|string',
]);
if ($validator->fails()) {
$errors[] = "Row " . ($index + 1) . ": " . implode(', ', $validator->errors()->all());
continue;
}
// Reject if a global product with this name already exists.
// Owners should pick the existing one via the fuzzy-search
// modal instead of creating a duplicate global entry.
$duplicate = Product::whereRaw('LOWER(TRIM(name)) = ?', [strtolower(trim((string) $productData['name']))])
->first();
if ($duplicate) {
$errors[] = "Row " . ($index + 1) . ": '{$productData['name']}' already exists globally. Use 'Pick existing' to import it instead.";
continue;
}
$product = Product::create([
'name' => $productData['name'],
'description' => $productData['description'] ?? '',
'price' => $productData['price'],
'unitname' => $productData['unitname'],
'available' => $productData['available'],
'barcode' => $productData['barcode'] ?? null,
'category' => $productData['category'] ?? null,
'subcategory' => $productData['subcategory'] ?? null,
'photourl' => $productData['photourl'] ?? [],
'created_by' => $user->id,
'is_active' => true,
]);
if ($targetStore) {
$targetStore->products()->attach($product->id, [
'available' => (int) $productData['available'],
'price' => (float) $productData['price'],
'description' => $productData['description'] ?? '',
'is_active' => true,
]);
}
$results[] = $product->hashkey;
}
if (!empty($errors)) {
DB::rollBack();
return Response::json(['success' => false, 'message' => 'Batch creation failed', 'errors' => $errors], 422);
}
DB::commit();
return Response::json(['success' => true, 'count' => count($results), 'data' => $results]);
} catch (\Throwable $th) {
DB::rollBack();
return ResponseHelper::returnError($th->getMessage());
}
}
/**
* Serve the batch products Excel template as a file download.
* Available for all authenticated users with batch module access.
*/
public function downloadProductTemplate()
{
$templatePath = BASE_PATH . '/resources/templates/batch-products-template.xlsx';
if (!file_exists($templatePath)) {
return Response::json(['success' => false, 'message' => 'Template file not found.'], 404);
}
$fileContent = file_get_contents($templatePath);
$filename = 'bukidbounty-batch-products-template.xlsx';
return Response::make($fileContent, 200, [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
'Content-Length' => strlen($fileContent),
'Cache-Control' => 'no-cache, no-store, must-revalidate',
'Pragma' => 'no-cache',
'Expires' => '0',
]);
}
/**
* Batch create stores.
* Available for Ultimate, Super Operator, and Operator.
*/
public function batchCreateStores(Request $request)
{
$user = Auth::user();
if (!$user) return ResponseHelper::returnUnauthorized();
$acctType = $user->acct_type instanceof UserTypes ? $user->acct_type : UserTypes::tryFrom($user->acct_type);
$isBig3 = in_array($acctType, [UserTypes::ULTIMATE, UserTypes::SUPER_OPERATOR, UserTypes::OPERATOR]);
if (!$isBig3) {
return ResponseHelper::returnUnauthorized();
}
$stores = $request->input('stores', []);
if (empty($stores)) {
return ResponseHelper::returnError('No stores provided');
}
$results = [];
$errors = [];
DB::beginTransaction();
try {
foreach ($stores as $index => $storeData) {
$validator = Validator::make($storeData, [
'name' => 'required|string|max:255',
'description' => 'required|string',
'address' => 'required|string',
'category' => 'nullable|string|max:100',
'subcategory' => 'nullable|string|max:100',
'owner_hash' => 'nullable|string',
]);
if ($validator->fails()) {
$errors[] = "Row " . ($index + 1) . ": " . implode(', ', $validator->errors()->all());
continue;
}
$ownerId = null;
if (!empty($storeData['owner_hash'])) {
$ownerId = UserController::findUserIdByHash($storeData['owner_hash']);
}
$storeCode = StoreController::generateStoreCode($storeData['category'] ?? 'General');
$store = Store::create([
'storecode' => $storeCode,
'name' => $storeData['name'],
'description' => $storeData['description'],
'address' => $storeData['address'],
'category' => $storeData['category'] ?? 'General',
'subcategory' => $storeData['subcategory'] ?? '',
'owner_id' => $ownerId,
'created_by' => $user->id,
'is_active' => true,
'status' => 'active',
]);
$results[] = $store->hashkey;
}
if (!empty($errors)) {
DB::rollBack();
return Response::json(['success' => false, 'message' => 'Batch creation failed', 'errors' => $errors], 422);
}
DB::commit();
return Response::json(['success' => true, 'count' => count($results), 'data' => $results]);
} catch (\Throwable $th) {
DB::rollBack();
return ResponseHelper::returnError($th->getMessage());
}
}
/**
* Batch create users.
* Available for Ultimate, Super Operator, and Operator.
*/
public function batchCreateUsers(Request $request)
{
$user = Auth::user();
if (!$user) return ResponseHelper::returnUnauthorized();
$acctType = $user->acct_type instanceof UserTypes ? $user->acct_type : UserTypes::tryFrom($user->acct_type);
$isBig3 = in_array($acctType, [UserTypes::ULTIMATE, UserTypes::SUPER_OPERATOR, UserTypes::OPERATOR]);
if (!$isBig3) {
return ResponseHelper::returnUnauthorized();
}
$usersData = $request->input('users', []);
if (empty($usersData)) {
return ResponseHelper::returnError('No users provided');
}
$results = [];
$errors = [];
DB::beginTransaction();
try {
foreach ($usersData as $index => $data) {
$validator = Validator::make($data, [
'username' => 'required|string|max:255|unique:users,username',
'name' => 'required|string|max:255',
'mobile_number' => ['required', 'string', 'max:20', 'unique:users,mobile_number', 'regex:/^(09|\+639)\d{9}$/'],
'password' => 'required|string|min:6',
'type' => 'required|string',
'parent_hash' => 'nullable|string',
]);
if ($validator->fails()) {
$errors[] = "Row " . ($index + 1) . ": " . implode(', ', $validator->errors()->all());
continue;
}
$usertypeEnum = UserTypes::tryFrom($data['type']);
if (!$usertypeEnum) {
$errors[] = "Row " . ($index + 1) . ": Invalid User Type";
continue;
}
// Check if creator is allowed to create this user type
if ($acctType !== UserTypes::ULTIMATE) {
$allowedTypes = UserTypeService::getAllowedUserTypes($acctType);
if (!in_array($usertypeEnum, $allowedTypes)) {
$errors[] = "Row " . ($index + 1) . ": You are not allowed to create user type '{$data['type']}'";
continue;
}
}
$parentId = $user->id; // Default to creator
if (!empty($data['parent_hash'])) {
$parent = User::where('hashkey', $data['parent_hash'])->first();
if ($parent) {
$parentId = $parent->id;
}
}
$newUser = User::create([
'username' => $data['username'],
'name' => $data['name'],
'mobile_number' => $data['mobile_number'],
'password' => Hash::make($data['password']),
'acct_type' => $data['type'],
'parentuid' => $parentId,
'active' => true,
]);
$results[] = $newUser->hashkey;
}
if (!empty($errors)) {
DB::rollBack();
return Response::json(['success' => false, 'message' => 'Batch creation failed', 'errors' => $errors], 422);
}
DB::commit();
return Response::json(['success' => true, 'count' => count($results), 'data' => $results]);
} catch (\Throwable $th) {
DB::rollBack();
return ResponseHelper::returnError($th->getMessage());
}
}
/**
* Batch create cooperatives.
* Available for Ultimate and Super Operator.
*/
public function batchCreateCooperatives(Request $request)
{
$user = Auth::user();
if (!$user) return ResponseHelper::returnUnauthorized();
$acctType = $user->acct_type instanceof UserTypes ? $user->acct_type : UserTypes::tryFrom($user->acct_type);
$isAllowed = in_array($acctType, [UserTypes::ULTIMATE, UserTypes::SUPER_OPERATOR]);
if (!$isAllowed) {
return ResponseHelper::returnUnauthorized();
}
$cooperatives = $request->input('cooperatives', []);
if (empty($cooperatives)) {
return ResponseHelper::returnError('No cooperatives provided');
}
$results = [];
$errors = [];
DB::beginTransaction();
try {
foreach ($cooperatives as $index => $data) {
$validator = Validator::make($data, [
'name' => 'required|string|max:255',
'address' => 'nullable|string',
'registration_number' => 'nullable|string|max:255',
'cin' => 'nullable|string|max:255',
'tin' => 'nullable|string|max:255',
'cooperative_type' => 'nullable|string|max:100',
'cooperative_category' => 'nullable|string|max:100',
'registration_date' => 'nullable|date',
'contact_person' => 'nullable|string|max:255',
'contact_number' => 'nullable|string|max:50',
'contact_email' => 'nullable|email|max:255',
]);
if ($validator->fails()) {
$errors[] = "Row " . ($index + 1) . ": " . implode(', ', $validator->errors()->all());
continue;
}
$cooperative = new Organization([
'hashkey' => Str::random(64),
'name' => trim($data['name']),
'type' => 'COOPERATIVE',
'address' => trim($data['address'] ?? ''),
'registration_number' => trim($data['registration_number'] ?? ''),
'cin' => trim($data['cin'] ?? ''),
'tin' => trim($data['tin'] ?? ''),
'cooperative_type' => trim($data['cooperative_type'] ?? ''),
'cooperative_category' => trim($data['cooperative_category'] ?? ''),
'registration_date' => $data['registration_date'] ?? null,
'contact_person' => trim($data['contact_person'] ?? ''),
'contact_number' => trim($data['contact_number'] ?? ''),
'contact_email' => trim($data['contact_email'] ?? ''),
'is_active' => true,
'created_by' => $user->id,
]);
if (!$cooperative->save()) {
$errors[] = "Row " . ($index + 1) . ": Failed to save cooperative";
continue;
}
$results[] = $cooperative->hashkey;
}
if (!empty($errors)) {
DB::rollBack();
return Response::json(['success' => false, 'message' => 'Batch creation failed', 'errors' => $errors], 422);
}
DB::commit();
return Response::json(['success' => true, 'count' => count($results), 'data' => $results]);
} catch (\Throwable $th) {
DB::rollBack();
return ResponseHelper::returnError($th->getMessage());
}
}
/**
* Batch add cooperative members with automatic user account creation.
* Each row creates a new User and a corresponding CooperativeMember in one transaction.
*/
public function batchCreateCooperativeMembers(Request $request)
{
$user = Auth::user();
if (!$user) return ResponseHelper::returnUnauthorized();
if (!UserPermissions::isActionPermitted($user->acct_type, UserActions::ManageOrganizations)) {
return ResponseHelper::returnUnauthorized();
}
$cooperativeHash = $request->input('cooperative_hash');
$members = $request->input('members', []);
if (!$cooperativeHash) {
return ResponseHelper::returnError('Cooperative hash is required');
}
if (empty($members)) {
return ResponseHelper::returnError('No members provided');
}
$cooperative = Organization::where('hashkey', $cooperativeHash)
->where('type', 'COOPERATIVE')
->first();
if (!$cooperative) {
return ResponseHelper::returnError('Cooperative not found', 404);
}
$parentUser = User::where('id', $cooperative->created_by)->first() ?? $user;
$results = [];
$errors = [];
DB::beginTransaction();
try {
foreach ($members as $index => $data) {
$validator = Validator::make($data, [
'username' => 'required|string|max:255|unique:users,username',
'name' => 'required|string|max:255',
'mobile_number' => ['required', 'string', 'max:20', 'unique:users,mobile_number', 'regex:/^(09|\+639)\d{9}$/'],
'password' => 'required|string|min:6',
'role' => 'nullable|string|max:50',
'membership_type' => 'nullable|string|max:50',
]);
if ($validator->fails()) {
$errors[] = 'Row ' . ($index + 1) . ': ' . implode(', ', $validator->errors()->all());
continue;
}
$newUser = new User();
$newUser->username = $data['username'];
$newUser->name = $data['name'];
$newUser->mobile_number = $data['mobile_number'];
$newUser->password = Hash::make($data['password']);
$newUser->parentuid = $parentUser->id;
$newUser->acct_type = 'user';
$newUser->active = true;
$newUser->save();
$member = new CooperativeMember([
'hashkey' => Str::random(64),
'organization_id' => $cooperative->id,
'user_id' => $newUser->id,
'role' => $data['role'] ?? 'MEMBER',
'membership_type' => $data['membership_type'] ?? null,
'joined_at' => now(),
'is_active' => true,
'created_by' => $user->id,
]);
if (!$member->save()) {
$errors[] = 'Row ' . ($index + 1) . ': Failed to save membership';
continue;
}
$results[] = [
'user_hashkey' => $newUser->hashkey,
'member_hashkey' => $member->hashkey,
];
}
if (!empty($errors)) {
DB::rollBack();
return Response::json(['success' => false, 'message' => 'Batch creation failed', 'errors' => $errors], 422);
}
DB::commit();
return Response::json(['success' => true, 'count' => count($results), 'data' => $results]);
} catch (\Throwable $th) {
DB::rollBack();
return ResponseHelper::returnError($th->getMessage());
}
}
}

View File

@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Market;
use App\Models\Market\Cart;
use App\Models\Market\CartItem;
use App\Models\Market\Product;
use Hypervel\Http\Request;
use Hypervel\Support\Facades\Auth;
use Hypervel\Support\Facades\Response;
use Hypervel\Support\Str;
class CartController
{
public function getCart()
{
$user = Auth::user();
if (!$user) {
return Response::json(['error' => 'Unauthorized'], 401);
}
$cart = Cart::firstOrCreate(['user_id' => $user->id]);
$items = $cart->items()->with('product')->get();
return Response::json([
'success' => true,
'cart' => $cart,
'items' => $items,
'total' => $items->sum(fn($item) => $item->price * $item->quantity)
]);
}
public function addItem(Request $request)
{
$user = Auth::user();
if (!$user) {
return Response::json(['error' => 'Unauthorized'], 401);
}
$request->validate([
'product_hash' => 'required|string',
'quantity' => 'nullable|integer|min:1',
]);
$product = Product::where('hashkey', $request->input('product_hash'))->first();
if (!$product) {
return Response::json(['error' => 'Product not found'], 404);
}
$cart = Cart::firstOrCreate(['user_id' => $user->id]);
$item = $cart->items()->where('product_id', $product->id)->first();
if ($item) {
$item->quantity += $request->input('quantity', 1);
$item->save();
} else {
$cart->items()->create([
'product_id' => $product->id,
'quantity' => $request->input('quantity', 1),
'price' => $product->price,
'is_active' => true,
'hashkey' => Str::uuid()->toString(),
]);
}
return Response::json(['success' => true, 'message' => 'Item added to cart']);
}
public function updateItem(Request $request)
{
$user = Auth::user();
if (!$user) {
return Response::json(['error' => 'Unauthorized'], 401);
}
$request->validate([
'item_hash' => 'required|string',
'quantity' => 'required|integer|min:1',
]);
$item = CartItem::where('hashkey', $request->input('item_hash'))->first();
if (!$item) {
return Response::json(['error' => 'Item not found'], 404);
}
// Verify cart ownership
$cart = Cart::find($item->cart_id);
if ($cart->user_id !== $user->id) {
return Response::json(['error' => 'Forbidden'], 403);
}
$item->quantity = $request->input('quantity');
$item->save();
return Response::json(['success' => true, 'message' => 'Cart updated']);
}
public function removeItem(Request $request)
{
$user = Auth::user();
if (!$user) {
return Response::json(['error' => 'Unauthorized'], 401);
}
$request->validate([
'item_hash' => 'required|string',
]);
$item = CartItem::where('hashkey', $request->input('item_hash'))->first();
if (!$item) {
return Response::json(['error' => 'Item not found'], 404);
}
$cart = Cart::find($item->cart_id);
if ($cart->user_id !== $user->id) {
return Response::json(['error' => 'Forbidden'], 403);
}
$item->delete();
return Response::json(['success' => true, 'message' => 'Item removed from cart']);
}
public function clearCart()
{
$user = Auth::user();
if (!$user) {
return Response::json(['error' => 'Unauthorized'], 401);
}
$cart = Cart::where('user_id', $user->id)->first();
if ($cart) {
$cart->items()->delete();
}
return Response::json(['success' => true, 'message' => 'Cart cleared']);
}
}

View File

@@ -0,0 +1,470 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Market;
use App\Http\Controllers\Helpers\ResponseHelper;
use App\Models\Market\CooperativeMember;
use App\Models\Market\Organization;
use App\Models\User;
use Hypervel\Http\Request;
use Hypervel\Support\Facades\Auth;
use Hypervel\Support\Facades\Hash;
use Hypervel\Support\Facades\Response;
use App\Http\Controllers\Helpers\Permissions\UserPermissions;
use App\Enums\UserActions;
use App\Enums\UserTypes;
use Hypervel\Support\Str;
class CooperativeController
{
public function listCooperatives(Request $request)
{
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ViewOrganizations)) {
return ResponseHelper::returnUnauthorized();
}
$query = Organization::where('is_active', true)->where('type', 'COOPERATIVE');
$cooperatives = $query->withCount('members')->get();
return response()->json([
'success' => true,
'data' => $cooperatives
]);
}
public function getCooperative(Request $request)
{
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ViewOrganizations)) {
return ResponseHelper::returnUnauthorized();
}
$hashkey = $request->input('hashkey');
if (!$hashkey) {
return ResponseHelper::returnIncorrectDetails();
}
$cooperative = Organization::where('hashkey', $hashkey)->with(['members.user.userInfo'])->first();
if (!$cooperative) {
return ResponseHelper::returnError('Cooperative not found', 404);
}
$currentUserMembership = null;
$user = Auth::user();
if ($user) {
$currentUserMembership = CooperativeMember::where('organization_id', $cooperative->id)
->where('user_id', $user->id)
->first();
}
return response()->json([
'success' => true,
'data' => $cooperative,
'is_member' => $currentUserMembership !== null,
'membership' => $currentUserMembership,
]);
}
public function createCooperative(Request $request)
{
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::CreateOrganization)) {
return ResponseHelper::returnUnauthorized();
}
$name = $request->input('name');
$address = $request->input('address', '');
$registrationNumber = $request->input('registration_number', '');
$cin = $request->input('cin', '');
$tin = $request->input('tin', '');
$cooperativeType = $request->input('cooperative_type', '');
$cooperativeCategory = $request->input('cooperative_category', '');
$registrationDate = $request->input('registration_date', null);
$contactPerson = $request->input('contact_person', '');
$contactNumber = $request->input('contact_number', '');
$contactEmail = $request->input('contact_email', '');
if (empty(trim($name ?? ''))) {
return ResponseHelper::returnError('Cooperative name is required');
}
$cooperative = new Organization([
'hashkey' => Str::random(64),
'name' => trim($name),
'type' => 'COOPERATIVE',
'address' => trim($address ?? ''),
'registration_number' => trim($registrationNumber),
'cin' => trim($cin),
'tin' => trim($tin),
'cooperative_type' => trim($cooperativeType),
'cooperative_category' => trim($cooperativeCategory),
'registration_date' => $registrationDate,
'contact_person' => trim($contactPerson),
'contact_number' => trim($contactNumber),
'contact_email' => trim($contactEmail),
'is_active' => true,
'created_by' => Auth::id(),
]);
if ($cooperative->save()) {
return ResponseHelper::returnSuccessResponse($cooperative, $cooperative->hashkey, 'Cooperative created successfully');
}
return ResponseHelper::returnError('Failed to create cooperative');
}
public function joinCooperative(Request $request)
{
$user = Auth::user();
if (!$user) {
return ResponseHelper::returnUnauthorized();
}
$cooperativeHash = $request->input('cooperative_hash');
if (!$cooperativeHash) {
return ResponseHelper::returnIncorrectDetails();
}
$cooperative = Organization::where('hashkey', $cooperativeHash)->first();
if (!$cooperative) {
return ResponseHelper::returnError('Cooperative not found', 404);
}
// Check if already a member
$existing = CooperativeMember::where('organization_id', $cooperative->id)
->where('user_id', $user->id)
->first();
if ($existing) {
return ResponseHelper::returnError('Already a member of this cooperative');
}
$memberFields = $request->only([
'role', 'membership_type', 'membership_level', 'officer_position', 'officer_level',
'concurrent_position', 'concurrent_level', 'cooperative_name_alt', 'cooperative_position', 'year_beginning',
'priority_sector', 'common_bond', 'vulnerability_classifications',
'philsys_id', 'sss_number', 'pagibig_number',
'slp_track', 'slp_association_name', 'listahanan_id', 'fourtps_household_id',
'tupad_category', 'tupad_insurance_beneficiary_name', 'tupad_insurance_beneficiary_relation',
'preferred_occupation', 'nsrp_skills', 'employment_status', 'program_participation',
]);
$member = new CooperativeMember(array_merge($memberFields, [
'organization_id' => $cooperative->id,
'user_id' => $user->id,
'role' => $request->input('role', 'MEMBER'),
'joined_at' => now(),
'is_active' => true,
]));
if ($member->save()) {
// Sync with user settings
$settings = $user->settings ?? [];
$cooperatives = $settings['cooperatives'] ?? [];
if (!in_array($cooperativeHash, $cooperatives)) {
$cooperatives[] = $cooperativeHash;
$settings['cooperatives'] = $cooperatives;
$user->settings = $settings;
$user->save();
}
return ResponseHelper::returnSuccessResponse($member, $member->hashkey, 'Successfully joined cooperative');
}
return ResponseHelper::returnError('Failed to join cooperative');
}
public function addMember(Request $request)
{
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ManageOrganizations)) {
return ResponseHelper::returnUnauthorized();
}
$cooperativeHash = $request->input('cooperative_hash');
$userHash = $request->input('user_hash');
if (!$cooperativeHash || !$userHash) {
return ResponseHelper::returnIncorrectDetails();
}
$cooperative = Organization::where('hashkey', $cooperativeHash)->first();
$targetUser = User::where('hashkey', $userHash)->first();
if (!$cooperative || !$targetUser) {
return ResponseHelper::returnError('Cooperative or User not found', 404);
}
$existing = CooperativeMember::where('organization_id', $cooperative->id)
->where('user_id', $targetUser->id)
->first();
if ($existing) {
return ResponseHelper::returnError('User is already a member');
}
$memberFields = $request->only([
'role', 'membership_type', 'membership_level', 'officer_position', 'officer_level',
'concurrent_position', 'concurrent_level', 'cooperative_name_alt', 'cooperative_position', 'year_beginning',
'priority_sector', 'common_bond', 'vulnerability_classifications',
'philsys_id', 'sss_number', 'pagibig_number',
'slp_track', 'slp_association_name', 'listahanan_id', 'fourtps_household_id',
'tupad_category', 'tupad_insurance_beneficiary_name', 'tupad_insurance_beneficiary_relation',
'preferred_occupation', 'nsrp_skills', 'employment_status', 'program_participation',
]);
$member = new CooperativeMember(array_merge($memberFields, [
'organization_id' => $cooperative->id,
'user_id' => $targetUser->id,
'role' => $request->input('role', 'MEMBER'),
'joined_at' => now(),
'is_active' => true,
]));
if ($member->save()) {
return ResponseHelper::returnSuccessResponse($member, $member->hashkey, 'Member added to cooperative');
}
return ResponseHelper::returnError('Failed to add member');
}
public function updateMember(Request $request)
{
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ManageOrganizations)) {
return ResponseHelper::returnUnauthorized();
}
$memberHash = $request->input('member_hash');
if (!$memberHash) {
return ResponseHelper::returnIncorrectDetails();
}
$member = CooperativeMember::where('hashkey', $memberHash)->first();
if (!$member) {
return ResponseHelper::returnError('Member record not found', 404);
}
$memberFields = $request->only([
'role', 'membership_type', 'membership_level', 'officer_position', 'officer_level',
'concurrent_position', 'concurrent_level', 'cooperative_name_alt', 'cooperative_position', 'year_beginning',
'is_active'
]);
$member->fill($memberFields);
if ($member->save()) {
return ResponseHelper::returnSuccessResponse($member, $member->hashkey, 'Membership details updated');
}
return ResponseHelper::returnError('Failed to update membership details');
}
public function registerMember(Request $request)
{
$user = Auth::user();
if (!$user) {
return ResponseHelper::returnUnauthorized();
}
if (!UserPermissions::isActionPermitted($user->acct_type, UserActions::JoinCooperative)) {
return ResponseHelper::returnUnauthorized();
}
$cooperativeHash = $request->input('cooperative_hash');
if (!$cooperativeHash) {
return ResponseHelper::returnIncorrectDetails();
}
$cooperative = Organization::where('hashkey', $cooperativeHash)->first();
if (!$cooperative) {
return ResponseHelper::returnError('Cooperative not found', 404);
}
// Check if already a member
$existing = CooperativeMember::where('organization_id', $cooperative->id)
->where('user_id', $user->id)
->first();
if ($existing) {
return ResponseHelper::returnError('Already a member of this cooperative');
}
$memberFields = $request->only([
'role', 'membership_type', 'membership_level', 'officer_position', 'officer_level',
'concurrent_position', 'concurrent_level', 'cooperative_name_alt', 'cooperative_position', 'year_beginning',
'priority_sector', 'common_bond', 'vulnerability_classifications',
'philsys_id', 'sss_number', 'pagibig_number',
'slp_track', 'slp_association_name', 'listahanan_id', 'fourtps_household_id',
'tupad_category', 'tupad_insurance_beneficiary_name', 'tupad_insurance_beneficiary_relation',
'preferred_occupation', 'nsrp_skills', 'employment_status', 'program_participation',
]);
$member = new CooperativeMember(array_merge($memberFields, [
'hashkey' => Str::random(64),
'organization_id' => $cooperative->id,
'user_id' => $user->id,
'role' => $request->input('role', 'MEMBER'),
'joined_at' => now(),
'is_active' => true,
'created_by' => $user->id,
]));
if ($member->save()) {
// Sync with user settings
$settings = $user->settings ?? [];
$cooperatives = $settings['cooperatives'] ?? [];
if (!is_array($cooperatives)) $cooperatives = [];
if (!in_array($cooperativeHash, $cooperatives)) {
$cooperatives[] = $cooperativeHash;
$settings['cooperatives'] = $cooperatives;
$user->settings = $settings;
$user->save();
}
// Upgrade a plain USER to COOP_MEMBER on cooperative registration.
// Never downgrade a higher type (COORDINATOR, OPERATOR, etc.).
if ($user->acct_type === UserTypes::USER) {
$user->acct_type = UserTypes::COOP_MEMBER;
$user->save();
}
return ResponseHelper::returnSuccessResponse($member, $member->hashkey, 'Successfully registered as a cooperative member');
}
return ResponseHelper::returnError('Failed to register');
}
public function publicCompleteMembership(Request $request)
{
$userHashkey = $request->input('user_hashkey');
$coopHash = $request->input('cooperative_hash');
if (!$userHashkey || !$coopHash) {
return ResponseHelper::returnIncorrectDetails();
}
$user = User::where('hashkey', $userHashkey)->first();
if (!$user) {
return response()->json(['success' => false, 'message' => 'User not found'], 404);
}
$cooperative = Organization::where('hashkey', $coopHash)
->where('type', 'COOPERATIVE')
->where('is_active', true)
->first();
if (!$cooperative) {
return response()->json(['success' => false, 'message' => 'Cooperative not found'], 404);
}
$existing = CooperativeMember::where('organization_id', $cooperative->id)
->where('user_id', $user->id)
->first();
if ($existing) {
return response()->json(['success' => false, 'message' => 'Already a member'], 409);
}
$member = new CooperativeMember(array_merge(
$request->only([
'membership_type', 'membership_level', 'year_beginning',
'officer_position', 'officer_level', 'concurrent_position', 'concurrent_level',
'cooperative_position', 'cooperative_name_alt',
'priority_sector', 'common_bond', 'vulnerability_classifications',
'philsys_id', 'sss_number', 'pagibig_number',
'slp_track', 'slp_association_name', 'listahanan_id', 'fourtps_household_id',
'tupad_category', 'tupad_insurance_beneficiary_name', 'tupad_insurance_beneficiary_relation',
'preferred_occupation', 'nsrp_skills', 'employment_status', 'program_participation',
]),
[
'hashkey' => Str::random(64),
'organization_id' => $cooperative->id,
'user_id' => $user->id,
'role' => 'MEMBER',
'joined_at' => now(),
'is_active' => true,
'created_by' => $user->id,
]
));
$member->save();
$settings = $user->settings ?? [];
$settings['cooperatives'] = array_unique(array_merge($settings['cooperatives'] ?? [], [$coopHash]));
$user->settings = $settings;
$user->save();
return response()->json(['success' => true, 'message' => 'Membership application submitted successfully.']);
}
public function publicGetCooperative(Request $request, string $hkey)
{
try {
$cooperative = Organization::where('hashkey', $hkey)
->where('type', 'COOPERATIVE')
->where('is_active', true)
->select(['id', 'hashkey', 'name', 'type', 'cooperative_type', 'cooperative_category', 'contact_person', 'contact_number', 'address'])
->first();
} catch (\Throwable $e) {
return Response::json(['success' => false, 'message' => 'Service temporarily unavailable'], 500);
}
if (!$cooperative) {
return Response::json(['success' => false, 'message' => 'Cooperative not found'], 404);
}
return Response::json(['success' => true, 'data' => $cooperative]);
}
public function publicRegisterMember(Request $request)
{
$hkey = $request->input('cooperative_hash');
if (!$hkey) {
return ResponseHelper::returnIncorrectDetails();
}
$cooperative = Organization::where('hashkey', $hkey)
->where('type', 'COOPERATIVE')
->where('is_active', true)
->first();
if (!$cooperative) {
return Response::json(['success' => false, 'message' => 'Cooperative not found'], 404);
}
try {
$validated = $request->validate([
'name' => 'required|string|max:255',
'username' => 'required|string|max:255|unique:users,username',
'mobile_number' => ['required', 'string', 'max:20', 'unique:users,mobile_number', 'regex:/^(09|\+639)\d{9}$/'],
'password' => 'required|string|min:6',
]);
} catch (\Hypervel\Validation\ValidationException $e) {
return Response::json(['success' => false, 'errors' => $e->errors()], 422);
}
$parentUser = User::where('id', $cooperative->created_by)->first()
?? User::where('acct_type', 'COORDINATOR')->first()
?? User::orderBy('id')->first();
if (!$parentUser) {
return Response::json(['success' => false, 'message' => 'No valid parent user found'], 500);
}
$user = new User();
$user->username = $validated['username'];
$user->name = $validated['name'];
$user->mobile_number = $validated['mobile_number'];
$user->password = Hash::make($validated['password']);
$user->parentuid = $parentUser->id;
$user->acct_type = 'user';
$user->active = true;
$user->save();
return Response::json([
'success' => true,
'user_hashkey' => $user->hashkey,
'message' => 'Account created. Please complete your membership application.',
], 201);
}
}

View File

@@ -0,0 +1,257 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Market;
use App\Http\Controllers\FilesMainController;
use App\Http\Controllers\Helpers\ResponseHelper;
use App\Models\Market\CooperativeDocument;
use App\Models\Market\Organization;
use App\Models\FileList;
use Hypervel\Http\Request;
use Hypervel\Support\Facades\Auth;
use App\Http\Controllers\Helpers\Permissions\UserPermissions;
use App\Enums\UserActions;
use Hypervel\Support\Str;
class CooperativeDocumentController
{
public function listDocuments(Request $request)
{
$orgHash = $request->input('orgHash');
if (!$orgHash) {
return ResponseHelper::returnIncorrectDetails();
}
$org = Organization::where('hashkey', $orgHash)->first();
if (!$org) {
return ResponseHelper::returnError('Organization not found', 404);
}
// Get latest versions (where parent_hashkey IS NULL or it is the latest in its group)
// For simplicity, we'll fetch all active docs and group them by parent_hashkey or hashkey
$allDocs = CooperativeDocument::where('organization_id', $org->id)
->where('is_active', true)
->orderBy('version_number', 'desc')
->get();
$fileHashes = $allDocs->pluck('file_hashkey')->filter()->unique()->toArray();
$fileLists = FileList::whereIn('hashkey', $fileHashes)->with('fileContent')->get()->keyBy('hashkey');
$grouped = [];
foreach ($allDocs as $doc) {
$rootKey = $doc->parent_hashkey ?? $doc->hashkey;
if (!isset($grouped[$rootKey])) {
$grouped[$rootKey] = [];
}
$grouped[$rootKey][] = $doc;
}
$data = [];
foreach ($grouped as $rootKey => $versions) {
$latest = $versions[0]; // ordered desc by version_number
$fileList = $fileLists[$latest->file_hashkey] ?? null;
if ($fileList) {
$history = [];
foreach ($versions as $v) {
$vFileList = $fileLists[$v->file_hashkey] ?? null;
if ($vFileList) {
$history[] = [
'hashkey' => $v->hashkey,
'version' => $v->version_number,
'name' => $vFileList->filename,
'date' => $v->created_at ? $v->created_at->format('Y-m-d H:i') : null,
'note' => $v->revision_note,
'url' => $vFileList->resolvedUrl()
];
}
}
$data[] = [
'hashkey' => $latest->hashkey,
'parent_hashkey' => $latest->parent_hashkey,
'name' => $fileList->filename,
'type' => $latest->document_type ?? 'Document',
'date' => $latest->created_at ? $latest->created_at->toDateString() : null,
'size' => $this->formatBytes($fileList->fileContent->size_in_bytes ?? 0),
'url' => $fileList->resolvedUrl(),
'version' => $latest->version_number,
'note' => $latest->revision_note,
'history' => $history
];
}
}
return response()->json([
'success' => true,
'data' => $data
]);
}
public function uploadDocument(Request $request)
{
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ManageOrganizations)) {
return ResponseHelper::returnUnauthorized();
}
$orgHash = $request->input('orgHash');
$docType = $request->input('type', 'OTHERS');
if (!$orgHash || !$request->hasFile('file')) {
return ResponseHelper::returnIncorrectDetails();
}
$org = Organization::where('hashkey', $orgHash)->first();
if (!$org) {
return ResponseHelper::returnError('Organization not found', 404);
}
$file = $request->file('file');
$filename = $file->getClientFilename();
$fileList = FilesMainController::uploadFileList(
$file,
$filename,
$filename,
'Cooperative Document for ' . $org->name,
['type' => 'coop_document', 'org_id' => $org->id],
'CooperativeDocuments',
[],
0,
'cooperative_document',
);
if (!$fileList || !isset($fileList->hashkey)) {
return ResponseHelper::returnError('File upload failed');
}
$doc = new CooperativeDocument([
'hashkey' => (string) Str::uuid(),
'organization_id' => $org->id,
'file_hashkey' => $fileList->hashkey,
'document_type' => $docType,
'created_by' => Auth::id(),
'is_active' => true,
]);
if ($doc->save()) {
return response()->json([
'success' => true,
'message' => 'Document uploaded successfully',
'data' => [
'hashkey' => $doc->hashkey,
'name' => $filename,
'url' => $fileList->resolvedUrl()
]
]);
}
return ResponseHelper::returnError('Failed to save document record');
}
public function reviseDocument(Request $request)
{
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ManageOrganizations)) {
return ResponseHelper::returnUnauthorized();
}
$parentHash = $request->input('parentHash');
$note = $request->input('note');
if (!$parentHash || !$request->hasFile('file')) {
return ResponseHelper::returnIncorrectDetails();
}
$parentDoc = CooperativeDocument::where('hashkey', $parentHash)->first();
if (!$parentDoc) {
return ResponseHelper::returnError('Original document not found', 404);
}
// The real parent is either its own parent or it is the parent
$rootHash = $parentDoc->parent_hashkey ?? $parentDoc->hashkey;
// Find highest version number
$lastVersion = CooperativeDocument::where('hashkey', $rootHash)
->orWhere('parent_hashkey', $rootHash)
->max('version_number');
$file = $request->file('file');
$filename = $file->getClientFilename();
$fileList = FilesMainController::uploadFileList(
$file,
$filename,
$filename,
'Revision of ' . $parentDoc->hashkey,
['type' => 'coop_document_revision', 'parent_id' => $parentDoc->id],
'CooperativeDocuments',
[],
0,
'cooperative_document_revision',
);
if (!$fileList) {
return ResponseHelper::returnError('File upload failed');
}
$doc = new CooperativeDocument([
'hashkey' => (string) Str::uuid(),
'parent_hashkey' => $rootHash,
'version_number' => $lastVersion + 1,
'organization_id' => $parentDoc->organization_id,
'file_hashkey' => $fileList->hashkey,
'document_type' => $parentDoc->document_type,
'revision_note' => $note,
'created_by' => Auth::id(),
'is_active' => true,
]);
if ($doc->save()) {
return response()->json([
'success' => true,
'message' => 'Revision uploaded successfully',
'data' => $doc
]);
}
return ResponseHelper::returnError('Failed to save revision record');
}
public function deleteDocument(Request $request)
{
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ManageOrganizations)) {
return ResponseHelper::returnUnauthorized();
}
$hashkey = $request->input('hashkey');
if (!$hashkey) {
return ResponseHelper::returnIncorrectDetails();
}
$doc = CooperativeDocument::where('hashkey', $hashkey)->first();
if (!$doc) {
return ResponseHelper::returnError('Document not found', 404);
}
// If it's a version, we might want to just deactivate that version.
// If it's the root, we might want to deactivate all versions.
// For now, let's just deactivate the specific one.
$doc->is_active = false;
if ($doc->save()) {
return response()->json(['success' => true, 'message' => 'Document/Revision deleted']);
}
return ResponseHelper::returnError('Failed to delete document');
}
private function formatBytes($bytes, $precision = 1)
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= (1 << (10 * $pow));
return round($bytes, $precision) . ' ' . $units[$pow];
}
}

View File

@@ -0,0 +1,269 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Market;
use App\Http\Controllers\Helpers\ResponseHelper;
use App\Http\Controllers\Helpers\PaymentProcessor;
use App\Http\Controllers\Helpers\QrphDecoder;
use App\Models\SystemSetting;
use App\Models\Accounting\MemberLedger;
use App\Models\User;
use App\Models\GlobalTransaction;
use Hypervel\Http\Request;
use Hypervel\Support\Facades\Auth;
use Hypervel\Support\Facades\DB;
use Hypervel\Support\Str;
use App\Enums\Market\ProductTransactionType;
use App\Enums\Market\TransactionFlow;
class CreditController
{
public function getWalletData(Request $request)
{
$user = User::find(Auth::id());
if (!$user) return ResponseHelper::returnUnauthorized();
$history = MemberLedger::where('user_id', $user->id)
->where('is_active', true)
->orderBy('created_at', 'desc')
->limit(20)
->get();
return response()->json([
'success' => true,
'balance' => $user->total_balance,
'credit' => $user->total_credit,
'history' => $history
]);
}
public function topUp(Request $request)
{
// Check if Top Up is enabled globally
if (!(\App\Models\SystemSetting::getValue('top_up_enabled', true))) {
return ResponseHelper::returnError('Credit top-up is currently disabled by administrators.');
}
$amount = (float) $request->input('amount');
$method = $request->input('method', 'GCASH');
if ($amount <= 0) {
return ResponseHelper::returnError('Amount must be greater than zero');
}
$user = User::find(Auth::id());
if (!$user) return ResponseHelper::returnUnauthorized();
// Start Transaction
DB::beginTransaction();
try {
// 1. Simulate Payment Success (in real life this would be a webhook/callback)
$payment = PaymentProcessor::initiatePayment($amount, $method, $user->hashkey);
if (!$payment['success']) {
throw new \Exception('Payment initiation failed');
}
// 2. Update User Balance
$user->total_balance += $amount;
$user->save();
// 3. Record in MemberLedger
$ledger = new MemberLedger([
'hashkey' => Str::random(64),
'user_id' => $user->id,
'amount' => $amount,
'transaction_type' => 'TOP_UP',
'flow' => 'IN',
'balance_after' => $user->total_balance,
'description' => "Credit Top-up via {$method}",
'reference_id' => $payment['transaction_id'],
'created_by' => $user->id,
'is_active' => true,
]);
$ledger->save();
// 4. Record in GlobalTransaction (for compatibility with existing reports)
$globalTxn = new GlobalTransaction([
'hashkey' => Str::random(64),
'user_id' => $user->id,
'amount' => $amount,
'type' => ProductTransactionType::TOP_UP,
'status' => 'COMPLETED',
'description' => "Credit Top-up via {$method}",
'flow' => TransactionFlow::INCOME,
'created_by' => $user->id,
]);
$globalTxn->save();
DB::commit();
return response()->json([
'success' => true,
'message' => 'Top-up successful',
'balance' => $user->total_balance,
'transaction_id' => $payment['transaction_id']
]);
} catch (\Exception $e) {
DB::rollBack();
return ResponseHelper::returnError('Top-up failed: ' . $e->getMessage());
}
}
public function transferCredit(Request $request)
{
$recipientHash = $request->input('recipient_hash');
$amount = (float) $request->input('amount');
if ($amount <= 0) return ResponseHelper::returnError('Invalid amount');
$sender = User::find(Auth::id());
if (!$sender) return ResponseHelper::returnUnauthorized();
if ($sender->total_balance < $amount) {
return ResponseHelper::returnError('Insufficient balance');
}
$recipient = User::where('hashkey', $recipientHash)->first();
if (!$recipient) return ResponseHelper::returnError('Recipient not found');
if ($sender->id === $recipient->id) {
return ResponseHelper::returnError('Cannot transfer to yourself');
}
DB::beginTransaction();
try {
// Deduct from sender
$sender->total_balance -= $amount;
$sender->save();
// Add to recipient
$recipient->total_balance += $amount;
$recipient->save();
$txnRef = Str::random(12);
// Record Ledger for Sender
MemberLedger::create([
'hashkey' => Str::random(64),
'user_id' => $sender->id,
'amount' => $amount,
'transaction_type' => 'TRANSFER_OUT',
'flow' => 'OUT',
'balance_after' => $sender->total_balance,
'description' => "Credit Transfer to {$recipient->fullname}",
'reference_id' => $txnRef,
'created_by' => $sender->id,
]);
// Record Ledger for Recipient
MemberLedger::create([
'hashkey' => Str::random(64),
'user_id' => $recipient->id,
'amount' => $amount,
'transaction_type' => 'TRANSFER_IN',
'flow' => 'IN',
'balance_after' => $recipient->total_balance,
'description' => "Credit Transfer from {$sender->fullname}",
'reference_id' => $txnRef,
'created_by' => $sender->id,
]);
DB::commit();
return response()->json(['success' => true, 'message' => 'Transfer successful']);
} catch (\Exception $e) {
DB::rollBack();
return ResponseHelper::returnError('Transfer failed');
}
}
public function searchUsers(Request $request)
{
$query = $request->input('q');
if (empty($query)) return response()->json(['success' => true, 'data' => []]);
$users = User::where('fullname', 'like', "%{$query}%")
->orWhere('name', 'like', "%{$query}%")
->orWhere('mobile_number', 'like', "%{$query}%")
->where('id', '!=', Auth::id())
->limit(10)
->get(['hashkey', 'fullname', 'name', 'mobile_number']);
return response()->json(['success' => true, 'data' => $users]);
}
/**
* GET the current QRPH payment code (available to all authenticated users for top-up display).
*/
public function getQrphCode(Request $request)
{
$raw = SystemSetting::getValue('qrph_payment_code', null);
$imgHashkey = SystemSetting::getValue('qrph_payment_image_hashkey', null);
if (empty($raw)) {
return response()->json(['success' => true, 'qrph' => null, 'decoded' => null, 'image_url' => null]);
}
$decoded = QrphDecoder::decode($raw);
$imageUrl = $imgHashkey ? "/RequestData/File/{$imgHashkey}" : null;
return response()->json([
'success' => true,
'qrph' => $raw,
'decoded' => $decoded,
'image_url' => $imageUrl,
]);
}
/**
* SET the QRPH payment code — ULTIMATE only (enforced by route middleware).
* Accepts optional image_hashkey from a prior /File/Upload/QrphPayment call.
*/
public function setQrphCode(Request $request)
{
$raw = trim($request->input('qrph_code', ''));
$imgHashkey = trim($request->input('image_hashkey', ''));
if (empty($raw)) {
SystemSetting::setValue('qrph_payment_code', '');
SystemSetting::setValue('qrph_payment_image_hashkey', '');
return response()->json(['success' => true, 'message' => 'QRPH code cleared.']);
}
$decoded = QrphDecoder::decode($raw);
SystemSetting::setValue('qrph_payment_code', $raw);
if (!empty($imgHashkey)) {
SystemSetting::setValue('qrph_payment_image_hashkey', $imgHashkey);
}
$imageUrl = $imgHashkey ? "/RequestData/File/{$imgHashkey}" : null;
return response()->json([
'success' => true,
'message' => 'QRPH code saved.',
'decoded' => $decoded,
'image_url' => $imageUrl,
]);
}
/**
* Decode a QRPH string without saving — for preview before saving.
*/
public function decodeQrph(Request $request)
{
$raw = trim($request->input('qrph_code', ''));
if (empty($raw)) {
return ResponseHelper::returnError('No QR string provided.');
}
return response()->json([
'success' => true,
'decoded' => QrphDecoder::decode($raw),
]);
}
}

View File

@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Market;
use App\Http\Controllers\Helpers\ResponseHelper;
use App\Models\Market\FarmerProfile;
use App\Models\Market\Organization;
use App\Models\User;
use Hypervel\Http\Request;
use Hypervel\Support\Facades\Auth;
use App\Http\Controllers\Helpers\Permissions\UserPermissions;
use App\Enums\UserActions;
class FarmerController
{
public function registerFarmer(Request $request)
{
$currentUser = Auth::user();
if (!$currentUser) {
return ResponseHelper::returnUnauthorized();
}
$validated = $request->validate([
'user_hash' => 'nullable|string',
'farm_name' => 'required|string|max:255',
'farm_location' => 'nullable|string',
'organization_hash' => 'nullable|string',
'main_crops' => 'nullable|array',
]);
// Determine the target user - if user_hash provided, use that user; otherwise use current user
if (!empty($validated['user_hash'])) {
$user = User::where('hashkey', $validated['user_hash'])->first();
if (!$user) {
return ResponseHelper::returnError('User not found', 404);
}
} else {
$user = $currentUser;
}
$organization = $validated['organization_hash'] ? Organization::where('hashkey', $validated['organization_hash'])->first() : null;
// Check if user already has a farmer profile
$existingProfile = FarmerProfile::where('user_id', $user->id)->first();
if ($existingProfile) {
return ResponseHelper::returnError('User already has a farmer profile');
}
$profile = new FarmerProfile([
'user_id' => $user->id,
'organization_id' => $organization?->id,
'farm_name' => $validated['farm_name'],
'farm_location' => $validated['farm_location'],
'main_crops' => $validated['main_crops'],
'verification_status' => 'VERIFIED',
'created_by' => $currentUser->id,
]);
if ($profile->save()) {
return ResponseHelper::returnSuccessResponse($profile, $profile->hashkey, 'Farmer profile created and pending verification');
}
return ResponseHelper::returnError('Failed to create farmer profile');
}
public function listFarmers(Request $request)
{
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ViewFarmers)) {
return ResponseHelper::returnUnauthorized();
}
$farmers = FarmerProfile::with(['user', 'organization'])->orderBy('created_at', 'desc')->get();
return response()->json([
'success' => true,
'data' => $farmers
]);
}
public function verifyFarmer(Request $request)
{
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::VerifyFarmer)) {
return ResponseHelper::returnUnauthorized();
}
$hashkey = $request->input('target');
$status = $request->input('status'); // VERIFIED, REJECTED
if (!$hashkey || !$status) {
return ResponseHelper::returnIncorrectDetails();
}
$profile = FarmerProfile::where('hashkey', $hashkey)->first();
if (!$profile) {
return ResponseHelper::returnError('Profile not found', 404);
}
$profile->verification_status = $status;
$profile->save();
return ResponseHelper::returnSuccessResponse($profile, $profile->hashkey, 'Farmer verification status updated');
}
public function listOrganizations()
{
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ViewOrganizations)) {
return ResponseHelper::returnUnauthorized();
}
$orgs = Organization::where('is_active', true)->get();
return response()->json([
'success' => true,
'data' => $orgs
]);
}
public function createOrganization(Request $request)
{
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::CreateOrganization)) {
return ResponseHelper::returnUnauthorized();
}
$validated = $request->validate([
'name' => 'required|string|max:255',
'type' => 'required|string|in:COOPERATIVE,ASSOCIATION,COMPANY',
'address' => 'nullable|string',
]);
$org = new Organization([
'name' => $validated['name'],
'type' => $validated['type'],
'address' => $validated['address'],
]);
if ($org->save()) {
return ResponseHelper::returnSuccessResponse($org, $org->hashkey, 'Organization created');
}
return ResponseHelper::returnError('Failed to create organization');
}
}

View File

@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Market;
use App\Http\Controllers\Helpers\ResponseHelper;
use App\Models\Market\CooperativeResolution;
use App\Models\Market\CooperativeVote;
use App\Models\Market\Organization;
use App\Models\Market\CooperativeMember;
use Hypervel\Http\Request;
use Hypervel\Support\Facades\Auth;
use App\Http\Controllers\Helpers\Permissions\UserPermissions;
use App\Enums\UserActions;
use Hypervel\Support\Str;
class GovernanceController
{
public function listResolutions(Request $request)
{
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ViewResolutions)) {
return ResponseHelper::returnUnauthorized();
}
$orgHash = $request->input('org_hash');
if (!$orgHash) {
return ResponseHelper::returnIncorrectDetails();
}
$org = Organization::where('hashkey', $orgHash)->first();
if (!$org) {
return ResponseHelper::returnError('Organization not found', 404);
}
$resolutions = CooperativeResolution::where('organization_id', $org->id)
->where('is_active', true)
->withCount(['votes as yes_votes' => function($query) {
$query->where('vote_cast', 'YES');
}])
->withCount(['votes as no_votes' => function($query) {
$query->where('vote_cast', 'NO');
}])
->withCount(['votes as abstain_votes' => function($query) {
$query->where('vote_cast', 'ABSTAIN');
}])
->orderBy('created_at', 'desc')
->get();
return response()->json([
'success' => true,
'data' => $resolutions
]);
}
public function createResolution(Request $request)
{
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::CreateResolution)) {
return ResponseHelper::returnUnauthorized();
}
$orgHash = $request->input('org_hash');
$title = $request->input('title');
$description = $request->input('description');
if (!$orgHash || !$title) {
return ResponseHelper::returnIncorrectDetails();
}
$org = Organization::where('hashkey', $orgHash)->first();
if (!$org) {
return ResponseHelper::returnError('Organization not found', 404);
}
$resolution = new CooperativeResolution([
'hashkey' => Str::random(64),
'organization_id' => $org->id,
'title' => trim($title),
'description' => trim($description ?? ''),
'status' => 'PROPOSED',
'created_by' => Auth::id(),
'is_active' => true,
]);
if ($resolution->save()) {
return ResponseHelper::returnSuccessResponse($resolution, $resolution->hashkey, 'Resolution created successfully');
}
return ResponseHelper::returnError('Failed to create resolution');
}
public function castVote(Request $request)
{
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::VoteResolution)) {
return ResponseHelper::returnUnauthorized();
}
$resolutionHash = $request->input('resolution_hash');
$vote = $request->input('vote'); // YES, NO, ABSTAIN
if (!$resolutionHash || !in_array($vote, ['YES', 'NO', 'ABSTAIN'])) {
return ResponseHelper::returnIncorrectDetails();
}
$resolution = CooperativeResolution::where('hashkey', $resolutionHash)->first();
if (!$resolution) {
return ResponseHelper::returnError('Resolution not found', 404);
}
// Check if user is a member of the organization
$isMember = CooperativeMember::where('organization_id', $resolution->organization_id)
->where('user_id', Auth::id())
->where('is_active', true)
->exists();
if (!$isMember) {
return ResponseHelper::returnError('Only active members can vote on resolutions', 403);
}
// Check for existing vote
$existingVote = CooperativeVote::where('resolution_id', $resolution->id)
->where('user_id', Auth::id())
->first();
if ($existingVote) {
$existingVote->vote_cast = $vote;
$existingVote->updated_by = Auth::id();
if ($existingVote->save()) {
return ResponseHelper::returnSuccessResponse($existingVote, $existingVote->hashkey, 'Vote updated successfully');
}
} else {
$newVote = new CooperativeVote([
'hashkey' => Str::random(64),
'resolution_id' => $resolution->id,
'user_id' => Auth::id(),
'vote_cast' => $vote,
'created_by' => Auth::id(),
'is_active' => true,
]);
if ($newVote->save()) {
return ResponseHelper::returnSuccessResponse($newVote, $newVote->hashkey, 'Vote cast successfully');
}
}
return ResponseHelper::returnError('Failed to record vote');
}
public function updateResolutionStatus(Request $request)
{
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ManageOrganizations)) {
return ResponseHelper::returnUnauthorized();
}
$resolutionHash = $request->input('resolution_hash');
$status = $request->input('status'); // APPROVED, RESCINDED
if (!$resolutionHash || !in_array($status, ['APPROVED', 'RESCINDED', 'PROPOSED'])) {
return ResponseHelper::returnIncorrectDetails();
}
$resolution = CooperativeResolution::where('hashkey', $resolutionHash)->first();
if (!$resolution) {
return ResponseHelper::returnError('Resolution not found', 404);
}
$resolution->status = $status;
$resolution->updated_by = Auth::id();
if ($status === 'APPROVED') {
$resolution->date_approved = now();
}
if ($resolution->save()) {
return ResponseHelper::returnSuccessResponse($resolution, $resolution->hashkey, 'Resolution status updated');
}
return ResponseHelper::returnError('Failed to update resolution status');
}
}

View File

@@ -0,0 +1,472 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Market;
use App\Enums\UserTypes;
use App\Http\Controllers\Helpers\ResponseHelper;
use App\Models\Market\PosSession;
use App\Models\Market\PosTransaction;
use App\Models\Market\Product;
use App\Models\Market\Store;
use App\Models\User;
use Hyperf\Stringable\Str;
use Hypervel\Http\Request;
use Hypervel\Support\Facades\DB;
use Hypervel\Support\Facades\Hash;
use Hypervel\Support\Facades\Response;
/**
* Performance / load-testing endpoints.
*
* These bypass session auth so a client machine can hit them with curl.
* Auth is via header `X-Perf-Token` matched against env `PERF_API_TOKEN`.
* If `PERF_API_TOKEN` is unset the endpoints are disabled (403 for all).
*
* All endpoints return timing metrics (ms) so the caller can chart how the
* box behaves under different batch sizes.
*/
class PerformanceController
{
private const DEFAULT_LIMIT = 1000;
private function authorize(Request $request)
{
$expected = (string) env('PERF_API_TOKEN', '');
if ($expected === '') {
return ResponseHelper::returnError('Performance API is disabled (PERF_API_TOKEN not set)', 403);
}
$provided = (string) ($request->header('X-Perf-Token') ?? $request->input('token', ''));
if (!hash_equals($expected, $provided)) {
return ResponseHelper::returnError('Invalid perf token', 401);
}
return null;
}
private function actingUser(): ?User
{
$hash = (string) env('PERF_ACTOR_HASH', '');
if ($hash !== '') {
$u = User::where('hashkey', $hash)->first();
if ($u) return $u;
}
return User::where('acct_type', UserTypes::ULTIMATE->value)
->orderBy('id', 'asc')
->first();
}
private function clampCount($raw): int
{
$n = (int) $raw;
if ($n < 1) $n = 1;
if ($n > self::DEFAULT_LIMIT) $n = self::DEFAULT_LIMIT;
return $n;
}
private function ms(float $start): float
{
return round((microtime(true) - $start) * 1000, 3);
}
public function ping(Request $request)
{
$deny = $this->authorize($request);
if ($deny) return $deny;
return Response::json([
'success' => true,
'ts' => now()->toIso8601String(),
'php' => PHP_VERSION,
]);
}
/**
* Core user seeder, returns ['hashes' => [...], 'ms' => float].
*/
private function _seedUsers(User $actor, int $count, string $type, ?int $parentId, string $prefix): array
{
$parentId = $parentId ?? $actor->id;
$hashed = Hash::make('Perf12345!');
$start = microtime(true);
$created = [];
DB::beginTransaction();
try {
foreach (range(1, $count) as $i) {
$suffix = Str::random(10);
$u = User::create([
'username' => "{$prefix}_{$suffix}",
'name' => "Perf User {$i}",
'mobile_number' => '09' . str_pad((string) random_int(0, 999999999), 9, '0', STR_PAD_LEFT),
'password' => $hashed,
'acct_type' => $type,
'parentuid' => $parentId,
'active' => true,
]);
$created[] = $u->hashkey;
}
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
throw $e;
}
return ['hashes' => $created, 'ms' => $this->ms($start)];
}
private function _seedStores(User $actor, int $count, string $category, ?int $ownerId, string $prefix): array
{
$start = microtime(true);
$created = [];
DB::beginTransaction();
try {
foreach (range(1, $count) as $i) {
$storeCode = StoreController::generateStoreCode($category);
$s = Store::create([
'storecode' => $storeCode,
'name' => "{$prefix} " . Str::random(8),
'description' => "Synthetic store for perf testing #{$i}",
'address' => 'Perf Lane ' . random_int(1, 9999),
'category' => $category,
'subcategory' => '',
'owner_id' => $ownerId,
'created_by' => $actor->id,
'is_active' => true,
'status' => 'active',
]);
$created[] = $s->hashkey;
}
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
throw $e;
}
return ['hashes' => $created, 'ms' => $this->ms($start)];
}
private function _seedProducts(User $actor, int $count, ?Store $store, bool $attach, string $prefix): array
{
$start = microtime(true);
$created = [];
DB::beginTransaction();
try {
foreach (range(1, $count) as $i) {
$price = random_int(10, 5000);
$available = random_int(10, 500);
$p = Product::create([
'name' => "{$prefix} " . Str::random(8),
'description' => "Synthetic product for perf testing #{$i}",
'price' => $price,
'unitname' => 'pc',
'available' => $available,
'category' => 'Perf',
'subcategory' => 'Synthetic',
'created_by' => $actor->id,
'is_active' => true,
]);
if ($store && $attach) {
$store->products()->attach($p->id, [
'available' => $available,
'price' => $price,
'description' => $p->description,
'is_active' => true,
]);
}
$created[] = $p->hashkey;
}
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
throw $e;
}
return ['hashes' => $created, 'ms' => $this->ms($start)];
}
/**
* POST /api/perf/seed/users
* body: { count, type?, parent_hash?, prefix? }
*/
public function seedUsers(Request $request)
{
$deny = $this->authorize($request);
if ($deny) return $deny;
$actor = $this->actingUser();
if (!$actor) return ResponseHelper::returnError('No actor user available', 500);
$count = $this->clampCount($request->input('count', 10));
$type = (string) $request->input('type', UserTypes::USER->value);
$prefix = (string) $request->input('prefix', 'perf');
if (!UserTypes::tryFrom($type)) {
return ResponseHelper::returnError("Invalid user type '{$type}'", 422);
}
$parentId = null;
if ($p = $request->input('parent_hash')) {
$parent = User::where('hashkey', (string) $p)->first();
if ($parent) $parentId = $parent->id;
}
try {
$r = $this->_seedUsers($actor, $count, $type, $parentId, $prefix);
} catch (\Throwable $e) {
return ResponseHelper::returnError($e->getMessage());
}
return Response::json([
'success' => true,
'count' => count($r['hashes']),
'total_ms' => $r['ms'],
'avg_ms' => round($r['ms'] / max(1, count($r['hashes'])), 3),
'sample' => array_slice($r['hashes'], 0, 5),
]);
}
/**
* POST /api/perf/seed/stores
* body: { count, owner_hash?, category?, prefix? }
*/
public function seedStores(Request $request)
{
$deny = $this->authorize($request);
if ($deny) return $deny;
$actor = $this->actingUser();
if (!$actor) return ResponseHelper::returnError('No actor user available', 500);
$count = $this->clampCount($request->input('count', 10));
$category = (string) $request->input('category', 'General');
$prefix = (string) $request->input('prefix', 'PerfStore');
$ownerId = null;
if ($h = $request->input('owner_hash')) {
$owner = User::where('hashkey', (string) $h)->first();
if ($owner) $ownerId = $owner->id;
}
try {
$r = $this->_seedStores($actor, $count, $category, $ownerId, $prefix);
} catch (\Throwable $e) {
return ResponseHelper::returnError($e->getMessage());
}
return Response::json([
'success' => true,
'count' => count($r['hashes']),
'total_ms' => $r['ms'],
'avg_ms' => round($r['ms'] / max(1, count($r['hashes'])), 3),
'sample' => array_slice($r['hashes'], 0, 5),
]);
}
/**
* POST /api/perf/seed/products
* body: { count, target_store_hash?, prefix?, attach_to_store? }
*/
public function seedProducts(Request $request)
{
$deny = $this->authorize($request);
if ($deny) return $deny;
$actor = $this->actingUser();
if (!$actor) return ResponseHelper::returnError('No actor user available', 500);
$count = $this->clampCount($request->input('count', 10));
$prefix = (string) $request->input('prefix', 'PerfProduct');
$attach = (bool) $request->input('attach_to_store', true);
$store = null;
if ($h = $request->input('target_store_hash')) {
$store = Store::where('hashkey', (string) $h)->first();
if (!$store) return ResponseHelper::returnError('Target store not found', 404);
}
try {
$r = $this->_seedProducts($actor, $count, $store, $attach, $prefix);
} catch (\Throwable $e) {
return ResponseHelper::returnError($e->getMessage());
}
return Response::json([
'success' => true,
'count' => count($r['hashes']),
'total_ms' => $r['ms'],
'avg_ms' => round($r['ms'] / max(1, count($r['hashes'])), 3),
'attached_to_store' => $store?->hashkey,
'sample' => array_slice($r['hashes'], 0, 5),
]);
}
/**
* POST /api/perf/seed/batch
* body: { users?, stores?, products?, prefix?, type?, category? }
* Runs all three seeders sequentially with per-phase timings.
*/
public function seedBatch(Request $request)
{
$deny = $this->authorize($request);
if ($deny) return $deny;
$actor = $this->actingUser();
if (!$actor) return ResponseHelper::returnError('No actor user available', 500);
$prefix = (string) $request->input('prefix', 'perf');
$type = (string) $request->input('type', UserTypes::USER->value);
if (!UserTypes::tryFrom($type)) {
return ResponseHelper::returnError("Invalid user type '{$type}'", 422);
}
$category = (string) $request->input('category', 'General');
$users = max(0, (int) $request->input('users', 0));
$stores = max(0, (int) $request->input('stores', 0));
$products = max(0, (int) $request->input('products', 0));
$phases = [];
$totalStart = microtime(true);
try {
if ($users > 0) {
$r = $this->_seedUsers($actor, $this->clampCount($users), $type, null, $prefix);
$phases['users'] = ['count' => count($r['hashes']), 'ms' => $r['ms']];
}
if ($stores > 0) {
$r = $this->_seedStores($actor, $this->clampCount($stores), $category, null, $prefix . 'Store');
$phases['stores'] = ['count' => count($r['hashes']), 'ms' => $r['ms']];
}
if ($products > 0) {
$r = $this->_seedProducts($actor, $this->clampCount($products), null, false, $prefix . 'Product');
$phases['products'] = ['count' => count($r['hashes']), 'ms' => $r['ms']];
}
} catch (\Throwable $e) {
return ResponseHelper::returnError($e->getMessage());
}
return Response::json([
'success' => true,
'total_ms' => $this->ms($totalStart),
'phases' => $phases,
]);
}
/**
* POST /api/perf/pos/simulate
* body: { store_hash, items?, cycles?, complete? }
*
* Runs end-to-end POS cycles entirely server-side: open session, add N
* line items, optionally complete + archive. Returns per-phase timings.
*/
public function simulatePos(Request $request)
{
$deny = $this->authorize($request);
if ($deny) return $deny;
$actor = $this->actingUser();
if (!$actor) return ResponseHelper::returnError('No actor user available', 500);
$storeHash = (string) $request->input('store_hash', '');
if ($storeHash === '') return ResponseHelper::returnError('store_hash is required', 422);
$store = Store::where('hashkey', $storeHash)->first();
if (!$store) return ResponseHelper::returnError('Store not found', 404);
$items = max(1, min(200, (int) $request->input('items', 5)));
$cycles = max(1, min(100, (int) $request->input('cycles', 1)));
$complete = (bool) $request->input('complete', true);
// Pull a pool of products attached to this store; fall back to global
// active products if the store has none yet.
$productIds = DB::table('prd_str')
->where('store_id', $store->id)
->where('is_active', true)
->pluck('product_id')
->toArray();
if (empty($productIds)) {
$productIds = Product::where('is_active', true)
->limit(max(50, $items * 2))
->pluck('id')
->toArray();
}
if (empty($productIds)) {
return ResponseHelper::returnError('No products available to simulate sales', 422);
}
$products = Product::whereIn('id', $productIds)->get(['id', 'hashkey', 'price'])->keyBy('id');
$cycleResults = [];
$totalStart = microtime(true);
for ($c = 1; $c <= $cycles; $c++) {
$cStart = microtime(true);
// 1. Open session
$t = microtime(true);
$session = PosSession::create([
'access_key' => Str::random(32),
'store_id' => $store->id,
'customer_name' => 'Perf Customer ' . $c,
'status' => 'active',
'created_by' => $actor->id,
]);
$openMs = $this->ms($t);
// 2. Add items (raw inserts for speed, mirrors PosController::addItem)
$t = microtime(true);
$now = now();
$rows = [];
$total = 0;
for ($i = 0; $i < $items; $i++) {
$pid = $productIds[array_rand($productIds)];
$product = $products[$pid] ?? null;
if (!$product) continue;
$qty = random_int(1, 5);
$price = (int) $product->price;
$line = $price * $qty;
$total += $line;
$rows[] = [
'pos_session_id' => $session->id,
'product_id' => $pid,
'quantity' => $qty,
'price_at_sale' => $price,
'total_price' => $line,
'hashkey' => Str::uuid()->toString() . Str::random(100),
'created_at' => $now,
'updated_at' => $now,
'created_by' => $actor->id,
];
}
if (!empty($rows)) {
DB::table('pos_transactions')->insert($rows);
}
$addMs = $this->ms($t);
// 3. Optionally complete the session
$completeMs = null;
if ($complete) {
$t = microtime(true);
DB::table('pos_sessions')->where('id', $session->id)->update([
'total_amount' => $total,
'received_amount' => $total,
'change_amount' => 0,
'status' => 'completed',
'payment_method' => 'cash',
'updated_at' => $now,
'updated_by' => $actor->id,
]);
$completeMs = $this->ms($t);
}
$cycleResults[] = [
'session_hash' => $session->hashkey,
'items' => count($rows),
'total' => $total,
'open_ms' => $openMs,
'add_items_ms' => $addMs,
'complete_ms' => $completeMs,
'cycle_ms' => $this->ms($cStart),
];
}
$totalMs = $this->ms($totalStart);
$cycleMsValues = array_column($cycleResults, 'cycle_ms');
$avg = $cycleMsValues ? array_sum($cycleMsValues) / count($cycleMsValues) : 0.0;
return Response::json([
'success' => true,
'store_hash' => $store->hashkey,
'cycles' => $cycles,
'items_per_cycle' => $items,
'total_ms' => $totalMs,
'avg_cycle_ms' => round($avg, 3),
'min_cycle_ms' => $cycleMsValues ? min($cycleMsValues) : 0,
'max_cycle_ms' => $cycleMsValues ? max($cycleMsValues) : 0,
'detail' => $cycleResults,
]);
}
}

View File

@@ -0,0 +1,774 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Market;
use App\Http\Controllers\Helpers\ResponseHelper;
use App\Models\Market\PosSession;
use App\Models\Market\PosTransaction;
use App\Models\Market\PosSessionArchive;
use App\Models\Market\Product;
use App\Models\Market\Store;
use Hypervel\Http\Request;
use Hypervel\Support\Facades\Auth;
use Hypervel\Support\Facades\Response;
use Hyperf\Stringable\Str;
use App\Models\Market\PosAccessKey;
use App\Models\Market\Customer;
use Hypervel\Support\Facades\DB;
use App\Http\Controllers\Helpers\Permissions\UserPermissions;
use App\Enums\UserActions;
use Hyperf\Coroutine\Coroutine;
use App\Http\Controllers\Helpers\CacheHelper;
class PosController
{
public function startSession(Request $request)
{
$validated = $request->validate([
'store_hash' => 'nullable|string',
'customer_name' => 'nullable|string|max:255',
'access_key' => 'nullable|string',
]);
$store = null;
$accessKeyObj = null;
if ($request->input('access_key')) {
$accessKeyObj = PosAccessKey::where('access_key', $request->input('access_key'))
->where('status', 'active')
->first();
if ($accessKeyObj) {
if ($accessKeyObj->isExpired()) {
$accessKeyObj->status = 'inactive';
$accessKeyObj->save();
return ResponseHelper::returnError('Access key has expired', 401);
}
$store = $accessKeyObj->store;
} elseif (!Auth::check()) {
return ResponseHelper::returnError('Invalid or inactive access key', 401);
}
}
if (!$store && !empty($validated['store_hash'])) {
$store = Store::where('hashkey', $validated['store_hash'])->first();
}
if (!$store) {
return ResponseHelper::returnError('No store found. Please open the POS from a store page or use a valid access key.', 422);
}
// Authorization check
if (!UserPermissions::isUserAllowedAccessToStore(Auth::user(), $store)) {
return ResponseHelper::returnError('You are not authorized to start a POS session for this store.', 403);
}
/** @var PosSession $session */
$session = PosSession::create([
'access_key' => $accessKeyObj ? $accessKeyObj->access_key : Str::random(32),
'store_id' => $store->id,
'customer_name' => $validated['customer_name'] ?? null,
'status' => 'active',
'created_by' => Auth::id(),
]);
if ($accessKeyObj) {
$accessKeyObj->last_used_at = now();
$accessKeyObj->save();
}
$this->archiveSession($session, 'Session initialized');
$this->invalidateSessionCache($session);
return ResponseHelper::returnSuccessResponse([
'hashkey' => $session->hashkey,
'access_key' => $session->access_key,
], $session->hashkey, 'POS Session started');
}
public function getSession(Request $request)
{
$hashkey = ResponseHelper::getTargetHash();
$accessKey = $request->input('access_key');
if (!$hashkey && !$accessKey) {
return ResponseHelper::returnError('No key provided', 400);
}
$session = null;
if ($hashkey) {
$q = $this->getBaseSessionQuery()->where('hashkey', $hashkey);
$session = CacheHelper::get_cache($q);
if (!$session) {
$session = $q->first();
if ($session) {
CacheHelper::set_cache($q, $session);
}
}
// If not a session hash, check if it's a store hash
if (!$session) {
$sq = Store::where('hashkey', $hashkey);
$store = CacheHelper::get_cache($sq);
if (!$store) {
$store = $sq->first();
if ($store) {
CacheHelper::set_cache($sq, $store);
}
}
if ($store) {
$q = $this->getBaseSessionQuery()
->where('store_id', $store->id)
->where('status', 'active')
->orderBy('id', 'desc');
$session = CacheHelper::get_cache($q);
if (!$session) {
$session = $q->first();
if ($session) {
CacheHelper::set_cache($q, $session);
}
}
}
}
}
// If still no session and we have an accessKey, try that (as fallback or primary if no hashkey)
if (!$session && $accessKey) {
$q = $this->getBaseSessionQuery()
->where('access_key', $accessKey)
->orderBy('id', 'desc');
$session = CacheHelper::get_cache($q);
if (!$session) {
$session = $q->first();
if ($session) {
CacheHelper::set_cache($q, $session);
}
}
}
if (!$session) {
return ResponseHelper::returnError('Session not found', 404);
}
// Authorization check
if (!UserPermissions::isUserAllowedAccessToStore(Auth::user(), $session->store_id)) {
return ResponseHelper::returnError('You are not authorized to access this POS session.', 403);
}
// Return the full session with all eager loaded relations
return ResponseHelper::returnSuccessResponse($session, $session->hashkey);
}
private function getBaseSessionQuery()
{
return PosSession::with([
'transactions.product' => function ($q) {
// Only fetch minimal columns needed for the POS to reduce serialization time
$q->select(['id', 'hashkey', 'name', 'price', 'photourl', 'unitname', 'category']);
},
'store'
]);
}
public function addItem(Request $request)
{
$validated = $request->validate([
'session_hash' => 'required|string',
'product_hash' => 'required|string',
'quantity' => 'required|integer|min:1',
'price' => 'nullable|numeric|min:0',
]);
$sq = PosSession::where('hashkey', $validated['session_hash']);
$session = CacheHelper::get_cache($sq);
if (!$session) {
$session = $sq->first();
if ($session) {
CacheHelper::set_cache($sq, $session);
}
}
$pq = Product::select(['id', 'hashkey', 'price', 'is_active', 'name'])->where('hashkey', $validated['product_hash']);
$product = CacheHelper::get_cache($pq);
if (!$product) {
$product = $pq->first();
if ($product) {
CacheHelper::set_cache($pq, $product);
}
}
if (!$session || !$product) {
return ResponseHelper::returnError('Session or Product not found', 404);
}
$sessionNeedsSave = false;
if ($session->status !== 'active') {
$session->status = 'active';
$sessionNeedsSave = true;
}
$price = (int) $product->price;
$isActive = $product->is_active;
if ($session->store_id) {
$psq = DB::table('prd_str')
->where('store_id', $session->store_id)
->where('product_id', $product->id)
->select('price', 'is_active');
$storeProduct = CacheHelper::get_cache($psq);
if (!$storeProduct) {
$storeProduct = $psq->first();
if ($storeProduct) {
CacheHelper::set_cache($psq, $storeProduct, [], 3600); // 1 hour
}
}
if ($storeProduct) {
if (isset($storeProduct->price)) {
$price = (int) $storeProduct->price;
}
if (isset($storeProduct->is_active)) {
$isActive = (bool) $storeProduct->is_active;
}
} else {
return ResponseHelper::returnError('Product not available in this store', 403);
}
}
if (!$isActive) {
return ResponseHelper::returnError('Product is currently inactive in this store', 403);
}
// Use custom price if provided, otherwise use calculated store/product price
if ($request->has('price')) {
$price = (int) $validated['price'];
}
// Update or create the transaction using raw DB for max speed
$existingTx = DB::table('pos_transactions')
->where('pos_session_id', $session->id)
->where('product_id', $product->id)
->first();
if ($existingTx) {
DB::table('pos_transactions')->where('id', $existingTx->id)->update([
'quantity' => $validated['quantity'],
'price_at_sale' => $price,
'total_price' => $price * $validated['quantity'],
'updated_at' => now(),
]);
} else {
DB::table('pos_transactions')->insert([
'pos_session_id' => $session->id,
'product_id' => $product->id,
'quantity' => $validated['quantity'],
'price_at_sale' => $price,
'total_price' => $price * $validated['quantity'],
'hashkey' => \Hyperf\Stringable\Str::uuid()->toString() . \Hyperf\Stringable\Str::random(100),
'created_at' => now(),
'updated_at' => now(),
'created_by' => Auth::id() ?? null,
]);
}
// Load specific columns to be fast, just like in getSession to reduce payload and memory
$session->load([
'transactions.product' => function ($q) {
$q->select(['id', 'hashkey', 'name', 'price', 'photourl', 'unitname', 'category']);
},
'store'
]);
$t_load = microtime(true);
// Update session totals in memory
$total = $session->transactions->where('is_void', false)->sum('total_price');
$updateData = [];
if ($session->total_amount !== (int) $total) {
$session->total_amount = (int) $total;
$updateData['total_amount'] = (int) $total;
}
if ($sessionNeedsSave) {
$updateData['status'] = $session->status;
}
// Use raw DB update to skip ModelSavingListener overhead while making sure we still record who updated it
if (!empty($updateData)) {
$updateData['updated_at'] = now();
$updateData['updated_by'] = Auth::id();
DB::table('pos_sessions')->where('id', $session->id)->update($updateData);
}
$t_db = microtime(true);
// Invalidate all possible session cache keys
$this->invalidateSessionCache($session);
// Archive the session using already loaded transaction data (deferred to background coroutine)
$this->archiveSession($session, 'Item added/updated: ' . $product->name, $session->transactions);
return ResponseHelper::returnSuccessResponse($session, $session->hashkey, 'Item added to session');
}
public function removeItem(Request $request)
{
$validated = $request->validate([
'session_hash' => 'required|string',
'transaction_id' => 'required|integer',
]);
$session = PosSession::where('hashkey', $validated['session_hash'])->first();
if (!$session) {
return ResponseHelper::returnError('Session not found', 404);
}
$transaction = PosTransaction::where('id', $validated['transaction_id'])
->where('pos_session_id', $session->id)
->first();
if ($transaction) {
$transaction->delete();
// Re-calculate and archive efficiently
// Load relations ONCE with only necessary columns
$session->load([
'transactions.product' => function ($q) {
$q->select(['id', 'hashkey', 'name', 'price', 'photourl', 'unitname', 'category']);
},
'store'
]);
$total = $session->transactions->where('is_void', false)->sum('total_price');
$session->total_amount = (int) $total;
DB::table('pos_sessions')->where('id', $session->id)->update([
'total_amount' => (int) $total,
'updated_at' => now(),
'updated_by' => Auth::id(),
]);
// Invalidate all possible session cache keys
$this->invalidateSessionCache($session);
$this->archiveSession($session, 'Item removed', $session->transactions);
} else {
$session->load([
'transactions.product' => function ($q) {
$q->select(['id', 'hashkey', 'name', 'price', 'photourl', 'unitname', 'category']);
},
'store'
]);
}
return ResponseHelper::returnSuccessResponse($session, $session->hashkey, 'Item removed from session');
}
public function completeSession(Request $request)
{
$validated = $request->validate([
'session_hash' => 'required|string',
'received_amount' => 'required|integer|min:0',
'payment_method' => 'required|string',
'payment_field' => 'nullable|string',
'customer_name' => 'nullable|string|max:255',
]);
$session = PosSession::where('hashkey', $validated['session_hash'])->first();
if (!$session) {
return ResponseHelper::returnError('Session not found', 404);
}
$session->received_amount = $validated['received_amount'];
$session->change_amount = $validated['received_amount'] - $session->total_amount;
$session->payment_method = $validated['payment_method'];
$session->payment_details = ['payment_field' => $validated['payment_field']];
if (!empty($validated['customer_name'])) {
$session->customer_name = $validated['customer_name'];
}
$session->status = 'completed';
$session->save();
// Invalidate cache
$this->invalidateSessionCache($session);
if (!empty($validated['customer_name'])) {
$customerName = trim($validated['customer_name']);
$customer = Customer::where('name', $customerName)
->where(function ($q) use ($session) {
$q->where('store_id', $session->store_id)
->orWhereNull('store_id');
})
->first();
if (!$customer) {
Customer::create([
'name' => $customerName,
'store_id' => $session->store_id,
'created_by' => Auth::id(),
]);
} else {
$customer->updated_at = now();
$customer->save();
}
}
$this->archiveSession($session, 'Session completed');
return ResponseHelper::returnSuccessResponse($session, $session->hashkey, 'Transaction completed');
}
public function syncOffline(Request $request)
{
$validated = $request->validate([
'transactions' => 'required|array',
'transactions.*.store_hash' => 'required|string',
'transactions.*.customer_name' => 'nullable|string',
'transactions.*.items' => 'required|array',
'transactions.*.total' => 'required|numeric',
'transactions.*.received' => 'required|numeric',
'transactions.*.method' => 'required|string',
'transactions.*.timestamp' => 'required|string',
'transactions.*.local_id' => 'nullable|integer',
]);
$syncedCount = 0;
$syncedIds = [];
$errors = [];
$affectedStoreIds = [];
foreach ($validated['transactions'] as $txn) {
try {
DB::beginTransaction();
$store = Store::where('hashkey', $txn['store_hash'])->first();
if (!$store) {
throw new \Exception('Store not found for hash: ' . $txn['store_hash']);
}
// Convert ISO 8601 timestamp to MySQL datetime format
$offlineTimestamp = date('Y-m-d H:i:s', strtotime($txn['timestamp']));
// Create the session
$session = new PosSession([
'store_id' => $store->id,
'customer_name' => $txn['customer_name'] ?? null,
'total_amount' => (int) $txn['total'],
'received_amount' => (int) $txn['received'],
'change_amount' => (int) ($txn['received'] - $txn['total']),
'payment_method' => $txn['method'],
'status' => 'completed',
'created_by' => Auth::id(),
'access_key' => 'synced-' . Str::random(32),
'hashkey' => Str::random(32) . '-' . Str::random(100),
]);
// Manually set timestamps to preserve offline time
$session->created_at = $offlineTimestamp;
$session->updated_at = $offlineTimestamp;
$session->save();
// Add Items
foreach ($txn['items'] as $item) {
$product = Product::where('hashkey', $item['product_hashkey'])->first();
if (!$product) continue;
DB::table('pos_transactions')->insert([
'pos_session_id' => $session->id,
'product_id' => $product->id,
'quantity' => $item['quantity'],
'price_at_sale' => (int) $item['price_at_sale'],
'total_price' => (int) ($item['price_at_sale'] * $item['quantity']),
'hashkey' => Str::uuid()->toString() . Str::random(100),
'created_at' => $offlineTimestamp,
'updated_at' => now(),
'created_by' => Auth::id(),
]);
}
// Handle Customer
if (!empty($txn['customer_name'])) {
$customerName = trim($txn['customer_name']);
$customer = Customer::where('name', $customerName)
->where(function ($q) use ($store) {
$q->where('store_id', $store->id)
->orWhereNull('store_id');
})
->first();
if (!$customer) {
Customer::create([
'name' => $customerName,
'store_id' => $store->id,
'created_by' => Auth::id(),
]);
}
}
$this->archiveSession($session, 'Offline synced transaction');
$this->invalidateSessionCache($session);
DB::commit();
$syncedCount++;
if (isset($txn['local_id'])) {
$syncedIds[] = $txn['local_id'];
}
if (!in_array($store->id, $affectedStoreIds)) {
$affectedStoreIds[] = $store->id;
}
} catch (\Exception $e) {
DB::rollBack();
$errors[] = $e->getMessage();
}
}
return ResponseHelper::returnSuccessResponse([
'synced_count' => $syncedCount,
'synced_ids' => $syncedIds,
'errors' => $errors
], 'sync_offline', "Synced $syncedCount transactions");
}
public function voidSession(Request $request)
{
$validated = $request->validate([
'session_hash' => 'required|string',
'remarks' => 'nullable|string',
]);
$session = PosSession::where('hashkey', $validated['session_hash'])->first();
if (!$session) {
return ResponseHelper::returnError('Session not found', 404);
}
if (!UserPermissions::isUserAllowedAccessToStore(Auth::user(), $session->store_id)) {
return ResponseHelper::returnError('You are not authorized to void this POS session.', 403);
}
$session->status = 'voided';
$session->is_void = true;
$session->save();
// Invalidate cache
$this->invalidateSessionCache($session);
$this->archiveSession($session, 'Session voided: ' . ($validated['remarks'] ?? 'No remarks'));
return ResponseHelper::returnSuccessResponse($session, $session->hashkey, 'Transaction voided');
}
public function getPosSessions(Request $request)
{
$validated = $request->validate([
'store_hash' => 'required|string',
'page' => 'nullable|integer|min:1',
'per_page' => 'nullable|integer|min:1|max:100',
]);
$store = Store::where('hashkey', $validated['store_hash'])->first();
if (!$store) {
return ResponseHelper::returnError('Store not found', 404);
}
// Authorization check
if (!UserPermissions::isUserAllowedAccessToStore(Auth::user(), $store)) {
return ResponseHelper::returnError('You are not authorized to view sessions for this store.', 403);
}
$page = (int) ($validated['page'] ?? 1);
$perPage = (int) ($validated['per_page'] ?? 10);
$offset = ($page - 1) * $perPage;
$query = PosSession::with(['transactions.product'])
->where('store_id', $store->id)
->where('status', '!=', 'active')
->orderBy('id', 'desc');
$totalCount = $query->count();
$sessions = $query->limit($perPage)->offset($offset)->get();
// Map sessions to include item count and simplify if needed
$sessions = $sessions->map(function ($session) {
return [
'hashkey' => $session->hashkey,
'status' => $session->status,
'total_amount' => $session->total_amount,
'customer_name' => $session->customer_name,
'payment_method' => $session->payment_method,
'items_count' => $session->transactions->count(),
'created_at' => $session->created_at,
'transactions' => $session->transactions,
];
});
return ResponseHelper::returnSuccessResponse([
'sessions' => $sessions,
'total_count' => $totalCount,
'page' => $page,
'per_page' => $perPage,
], $store->hashkey, 'POS sessions fetched');
}
public function getTodayStats(Request $request)
{
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ViewPosReports)) {
return ResponseHelper::returnUnauthorized();
}
$date = now()->format('Y-m-d');
$query = PosSession::where('status', 'completed')
->whereDate('created_at', $date);
$storeName = null;
$storePhoto = null;
// If store_hash is provided, filter by store
if ($request->input('store_hash')) {
$store = Store::where('hashkey', $request->input('store_hash'))->first();
if ($store) {
// Authorization check
if (!UserPermissions::isUserAllowedAccessToStore(Auth::user(), $store)) {
return ResponseHelper::returnError('You are not authorized to view reports for this store.', 403);
}
$query->where('store_id', $store->id);
$storeName = $store->name;
$storePhoto = $store->photourl;
}
}
$stats = CacheHelper::get_cache($query);
if ($stats) {
return ResponseHelper::returnSuccessResponse($stats, 'today_stats');
}
$stats = [
'count' => (int) $query->count(),
'total' => (int) $query->sum('total_amount'),
'store_name' => $storeName,
'store_photo' => $storePhoto,
];
CacheHelper::set_cache($query, $stats, [], 300); // 5 minutes
return ResponseHelper::returnSuccessResponse($stats, 'today_stats');
}
public function getCustomers(Request $request)
{
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ViewCustomers)) {
return ResponseHelper::returnUnauthorized();
}
$queryText = $request->input('query');
$storeHash = $request->input('store_hash');
$customerQuery = Customer::where('is_active', true);
if ($storeHash) {
$store = Store::where('hashkey', $storeHash)->first();
if ($store) {
// Authorization check
if (!UserPermissions::isUserAllowedAccessToStore(Auth::user(), $store)) {
return ResponseHelper::returnError('You are not authorized to view customers for this store.', 403);
}
$customerQuery->where(function ($q) use ($store) {
$q->where('store_id', $store->id)
->orWhereNull('store_id');
});
}
}
if ($queryText) {
$customerQuery->where('name', 'like', '%' . $queryText . '%');
}
$finalQuery = $customerQuery->orderBy('name', 'asc')->limit(10);
$customers = CacheHelper::get_cache($finalQuery);
if (!$customers) {
$customers = $finalQuery->get();
CacheHelper::set_cache($finalQuery, $customers, [], 3600); // 1 hour
}
return ResponseHelper::returnSuccessResponse($customers, 'customer_suggestions');
}
private function updateSessionTotals(PosSession $session)
{
$total = $session->transactions()->where('is_void', false)->sum('total_price');
$session->total_amount = (int) $total;
$session->save();
}
private function archiveSession(PosSession $session, string $remarks = '', $transactions = null)
{
// Serialize all data NOW before spawning coroutine to avoid context issues
$sessionData = $session->toArray();
$sessionId = $session->id;
$sessionHashkey = $session->hashkey;
$userId = Auth::id();
if ($transactions !== null) {
$transactionsData = $transactions->toArray();
} else {
$transactionsData = $session->transactions()->with('product')->get()->toArray();
}
// Defer the archive INSERT to a background coroutine so it doesn't block the response
Coroutine::create(function () use ($sessionId, $sessionHashkey, $sessionData, $transactionsData, $userId, $remarks) {
try {
PosSessionArchive::create([
'pos_session_id' => $sessionId,
'hashkey' => $sessionHashkey,
'session_snapshot' => $sessionData,
'transactions_snapshot' => $transactionsData,
'created_by' => $userId,
'remarks' => $remarks,
]);
} catch (\Throwable $e) {
// Silently fail — archive is non-critical audit data
}
});
}
private function invalidateSessionCache(PosSession $session)
{
// 1. Invalidate by hashkey (with relations)
CacheHelper::erase_cache($this->getBaseSessionQuery()->where('hashkey', $session->hashkey));
// 1b. Invalidate simple hashkey lookup (used in addItem)
CacheHelper::erase_cache(PosSession::where('hashkey', $session->hashkey));
// 2. Invalidate by store_id (last active session)
if ($session->store_id) {
CacheHelper::erase_cache($this->getBaseSessionQuery()
->where('store_id', $session->store_id)
->where('status', 'active')
->orderBy('id', 'desc'));
}
// 3. Invalidate by access_key
if ($session->access_key) {
CacheHelper::erase_cache($this->getBaseSessionQuery()
->where('access_key', $session->access_key)
->orderBy('id', 'desc'));
}
// 4. Invalidate today stats cache for the store
if ($session->store_id) {
$date = now()->format('Y-m-d');
$statsQuery = PosSession::where('status', 'completed')
->whereDate('created_at', $date)
->where('store_id', $session->store_id);
CacheHelper::erase_cache($statsQuery);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,190 @@
<?php
namespace App\Http\Controllers\Market;
use App\Http\Controllers\Controller;
use App\Http\Controllers\FilesMainController;
use App\Http\Controllers\Helpers\Permissions\UserPermissions;
use App\Enums\UserActions;
use Hypervel\Http\Request;
use Hypervel\Support\Facades\Auth;
class ProductPhotoSearchController extends Controller
{
private const DDG_BROWSER_HEADERS = [
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'Accept-Language: en-US,en;q=0.9',
'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Referer: https://duckduckgo.com/',
];
// Step 1: fetch the DDG search page to extract the vqd session token.
// DDG requires this token to serve the image JSON endpoint.
private static function getDdgVqd(string $query): ?string
{
$url = 'https://duckduckgo.com/?q=' . urlencode($query) . '&iax=images&ia=images';
$ctx = stream_context_create(['http' => [
'method' => 'GET',
'header' => implode("\r\n", self::DDG_BROWSER_HEADERS),
'timeout' => 10,
]]);
$html = @file_get_contents($url, false, $ctx);
if (!$html) return null;
// The vqd token appears in the page JS in several formats depending on
// DDG's current build. Try the quoted forms first (vqd="4-xxx" /
// vqd='4-xxx' / vqd:"4-xxx"), then fall back to the bare form.
$patterns = [
'/vqd=["\']([0-9a-zA-Z._\-]+)["\']/', // vqd="4-123..." or vqd='4-123...'
'/vqd["\']?\s*[:=]\s*["\']([0-9a-zA-Z._\-]+)["\']/', // vqd:"4-123..."
'/vqd=([0-9a-zA-Z._\-]+)/', // bare vqd=4-123...
];
foreach ($patterns as $pattern) {
if (preg_match($pattern, $html, $m) && !empty($m[1])) {
return $m[1];
}
}
return null;
}
// GET /api/products/photo-search?q=...&page=1
public function search(Request $request)
{
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::SearchStockPhotos)) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$query = trim($request->input('q', ''));
$page = max(1, (int) $request->input('page', 1));
if (!$query) {
return response()->json(['error' => 'Query required'], 422);
}
$vqd = self::getDdgVqd($query);
if (!$vqd) {
return response()->json(['error' => 'Could not reach image search service'], 502);
}
// s = offset; DDG returns ~15 results per call; page 1 = s=0, page 2 = s=15, etc.
$offset = ($page - 1) * 15;
$url = 'https://duckduckgo.com/i.js?' . http_build_query([
'q' => $query,
'vqd' => $vqd,
'o' => 'json',
'p' => '1',
'f' => ',,,',
'l' => 'us-en',
's' => $offset,
]);
$ctx = stream_context_create(['http' => [
'method' => 'GET',
'header' => implode("\r\n", self::DDG_BROWSER_HEADERS),
'timeout' => 10,
]]);
$raw = @file_get_contents($url, false, $ctx);
if ($raw === false) {
return response()->json(['error' => 'Failed to fetch image results'], 502);
}
$data = json_decode($raw, true);
$results = $data['results'] ?? [];
$photos = array_map(fn($r) => [
'id' => md5($r['image']), // stable ID from image URL
'thumb' => $r['thumbnail'], // DDG-proxied small thumb (safe to display)
'src' => $r['image'], // actual source image URL (used for download)
'title' => $r['title'] ?? '',
], array_slice($results, 0, 15));
return response()->json([
'success' => true,
'photos' => $photos,
'page' => $page,
'has_more' => count($results) >= 15,
]);
}
// POST /api/products/photo-download
// body: { src: "https://..." } — the actual source image URL from DDG results
public function download(Request $request)
{
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::DownloadStockPhoto)) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$src = $request->input('src', '');
// SSRF guard: must be http/https and must not target private/loopback IPs
$parsed = parse_url($src);
$scheme = $parsed['scheme'] ?? '';
$host = strtolower($parsed['host'] ?? '');
if (!in_array($scheme, ['http', 'https']) || !$host) {
return response()->json(['error' => 'Invalid URL'], 422);
}
// Block private/loopback ranges
if (preg_match('/^(localhost|127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|0\.0\.0\.0|::1)/i', $host)) {
return response()->json(['error' => 'Forbidden URL'], 403);
}
$ctx = stream_context_create(['http' => [
'method' => 'GET',
'header' => 'User-Agent: Mozilla/5.0' . "\r\n",
'timeout' => 15,
]]);
$raw = @file_get_contents($src, false, $ctx);
if ($raw === false || strlen($raw) < 500) {
return response()->json(['error' => 'Failed to fetch image'], 502);
}
// Resize to max 1280x720 using PHP GD (bundled — no Intervention Image needed)
$srcImg = @imagecreatefromstring($raw);
if (!$srcImg) {
return response()->json(['error' => 'Invalid image data'], 422);
}
$origW = imagesx($srcImg);
$origH = imagesy($srcImg);
$maxW = 1280;
$maxH = 720;
$ratio = min($maxW / $origW, $maxH / $origH, 1.0); // never upscale
$newW = (int) round($origW * $ratio);
$newH = (int) round($origH * $ratio);
$dstImg = imagescale($srcImg, $newW, $newH, IMG_BILINEAR_FIXED);
imagedestroy($srcImg);
ob_start();
imagejpeg($dstImg, null, 85);
$binary = ob_get_clean();
imagedestroy($dstImg);
// Save via existing pipeline — binary string branch in uploadFileContent handles this
$result = FilesMainController::uploadFileList(
$binary,
'stock-photo',
'stock_photo_' . time() . '.jpg',
'',
[],
'ProductMarket',
[],
0,
'image/jpeg'
);
if (!$result || empty($result->hashkey)) {
return response()->json(['error' => 'Save failed'], 500);
}
return response()->json([
'success' => true,
'hashkey' => $result->hashkey,
'url' => $result->resolvedUrl(),
]);
}
}

View File

@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Market;
use App\Http\Controllers\Helpers\ResponseHelper;
use App\Models\Market\Courier;
use App\Models\Market\Shipment;
use App\Models\Market\Store;
use App\Models\Market\Customer;
use App\Models\GlobalTransaction;
use Hypervel\Http\Request;
use Hypervel\Support\Facades\Auth;
use App\Http\Controllers\Helpers\Permissions\UserPermissions;
use App\Enums\UserActions;
class ShipmentController
{
public function listShipments(Request $request)
{
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ViewShipments)) {
return ResponseHelper::returnUnauthorized();
}
$user = Auth::user();
$query = Shipment::with(['courier', 'transaction', 'store', 'customer']);
// filter by store if provided
if ($storeHash = $request->input('store_hash')) {
$store = Store::where('hashkey', $storeHash)->first();
if ($store) {
$query->where('store_id', $store->id);
}
}
// if not ultimate/admin, restrict to user's shipments
// (This logic might need adjustment based on how roles are defined)
// For now, let's just list all and allow filtering
$shipments = $query->orderBy('created_at', 'desc')->get();
return response()->json([
'success' => true,
'data' => $shipments
]);
}
public function createNewShipment(Request $request)
{
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::CreateShipment)) {
return ResponseHelper::returnUnauthorized();
}
$user = Auth::user();
$validated = $request->validate([
'transaction_hash' => 'required|string',
'store_hash' => 'nullable|string',
'customer_hash' => 'nullable|string',
'courier_hash' => 'nullable|string',
'origin_address' => 'nullable|string',
'destination_address' => 'nullable|string',
'shipping_fee' => 'nullable|numeric',
'estimated_delivery_date' => 'nullable|date',
]);
$transaction = GlobalTransaction::where('hashkey', $validated['transaction_hash'])->first();
if (!$transaction) {
return ResponseHelper::returnError('Transaction not found', 404);
}
$store = $validated['store_hash'] ? Store::where('hashkey', $validated['store_hash'])->first() : null;
$customer = $validated['customer_hash'] ? Customer::where('hashkey', $validated['customer_hash'])->first() : null;
$courier = $validated['courier_hash'] ? Courier::where('hashkey', $validated['courier_hash'])->first() : null;
$shipment = new Shipment([
'transaction_id' => $transaction->id,
'store_id' => $store?->id,
'customer_id' => $customer?->id,
'courier_id' => $courier?->id,
'origin_address' => $validated['origin_address'] ?? $store?->address,
'destination_address' => $validated['destination_address'] ?? $customer?->address,
'shipping_fee' => $validated['shipping_fee'] ?? 0,
'estimated_delivery_date' => $validated['estimated_delivery_date'],
'status' => 'PENDING',
'created_by' => $user->id,
]);
if ($shipment->save()) {
return ResponseHelper::returnSuccessResponse($shipment, $shipment->hashkey, 'Shipment created successfully');
}
return ResponseHelper::returnError('Failed to create shipment');
}
public function updateShipmentStatus(Request $request)
{
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::UpdateShipmentStatus)) {
return ResponseHelper::returnUnauthorized();
}
$hashkey = $request->input('target');
$status = $request->input('status');
if (!$hashkey || !$status) {
return ResponseHelper::returnIncorrectDetails();
}
$shipment = Shipment::where('hashkey', $hashkey)->first();
if (!$shipment) {
return ResponseHelper::returnError('Shipment not found', 404);
}
$shipment->status = $status;
if ($status === 'DELIVERED') {
$shipment->actual_delivery_date = now();
}
$shipment->save();
return ResponseHelper::returnSuccessResponse($shipment, $shipment->hashkey, 'Shipment status updated');
}
public function listCouriers()
{
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ViewCouriers)) {
return ResponseHelper::returnUnauthorized();
}
$couriers = Courier::where('is_active', true)->get();
return response()->json([
'success' => true,
'data' => $couriers
]);
}
public function createCourier(Request $request)
{
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::CreateCourier)) {
return ResponseHelper::returnUnauthorized();
}
$validated = $request->validate([
'name' => 'required|string|max:255',
'contact_number' => 'nullable|string',
'type' => 'required|string|in:INTERNAL,EXTERNAL',
]);
$courier = new Courier([
'name' => $validated['name'],
'contact_number' => $validated['contact_number'],
'type' => $validated['type'],
]);
if ($courier->save()) {
return ResponseHelper::returnSuccessResponse($courier, $courier->hashkey, 'Courier created');
}
return ResponseHelper::returnError('Failed to create courier');
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,565 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Market;
use App\Enums\UserActions;
use App\Http\Controllers\Helpers\Permissions\UserPermissions;
use App\Http\Controllers\Helpers\ResponseHelper;
use App\Models\User;
use App\Models\Market\Store;
use App\Models\Market\Product;
use App\Models\GlobalTransaction;
use App\Models\FileContent;
use App\Models\DbBackup;
use Hyperf\Stringable\Str;
use Hypervel\Http\Request;
use Hypervel\Support\Facades\Auth;
use Hypervel\Support\Facades\DB;
use Hypervel\Support\Facades\Redis;
use Hypervel\Support\Facades\Response;
class UltimateController
{
/**
* Common check for Ultimate access.
*/
private function checkAccess()
{
if (!UserPermissions::isActionPermitted(0, UserActions::UltimateConsole)) {
return false;
}
return true;
}
/**
* Get system-wide statistics for the dashboard.
*/
public function getSystemStats()
{
if (!$this->checkAccess()) return ResponseHelper::returnUnauthorized();
$globalMessage = Redis::get('system:global_message');
$redisStatus = ['connected' => false, 'ping_ms' => null, 'used_memory_human' => null, 'version' => null, 'error' => null];
try {
$start = microtime(true);
$pong = Redis::ping();
$redisStatus['ping_ms'] = round((microtime(true) - $start) * 1000, 2);
$redisStatus['connected'] = $pong === true || $pong === 'PONG' || $pong === '+PONG' || (is_string($pong) && stripos($pong, 'PONG') !== false);
$info = Redis::info();
if (is_array($info)) {
$flat = isset($info['Memory']) && is_array($info['Memory']) ? $info['Memory'] : $info;
$redisStatus['used_memory_human'] = $flat['used_memory_human'] ?? null;
$serverInfo = isset($info['Server']) && is_array($info['Server']) ? $info['Server'] : $info;
$redisStatus['version'] = $serverInfo['redis_version'] ?? null;
}
} catch (\Throwable $e) {
$redisStatus['error'] = $e->getMessage();
}
$stats = [
'users' => User::count(),
'active_users' => User::where('active', true)->count(),
'stores' => Store::count(),
'active_stores' => Store::where('is_active', true)->count(),
'products' => Product::count(),
'transactions' => GlobalTransaction::count(),
'total_balance' => GlobalTransaction::sum('amount'),
'php_version' => PHP_VERSION,
'server_time' => date('Y-m-d H:i:s'),
'maintenance_mode' => Redis::get('system:maintenance_mode') === 'true',
'global_message' => $globalMessage ? json_decode($globalMessage, true) : null,
'logs_count' => DB::table('logs')->count(),
'table_logs_count' => DB::table('table_logs')->count(),
'pos_sessions_count' => DB::table('pos_sessions')->count(),
'cooperatives_count' => DB::table('organizations')->where('type', 'COOPERATIVE')->count(),
'carts_count' => DB::table('carts')->count(),
'farmer_profiles_count' => DB::table('farmer_profiles')->count(),
'redis' => $redisStatus,
];
return Response::json(['success' => true, 'data' => $stats]);
}
/**
* Execute a raw SQL query.
*/
public function runQuery(Request $request)
{
if (Auth::user()->acct_type !== \App\Enums\UserTypes::ULTIMATE || !UserPermissions::isActionPermitted(0, UserActions::UltimateQuery)) {
return ResponseHelper::returnUnauthorized();
}
$query = $request->input('query');
if (empty($query)) return ResponseHelper::returnError('Query cannot be empty');
try {
$queryLower = strtolower(trim($query));
if (str_starts_with($queryLower, 'select') || str_starts_with($queryLower, 'show') || str_starts_with($queryLower, 'describe')) {
$results = DB::select($query);
return Response::json(['success' => true, 'data' => $results]);
} else {
$affected = DB::statement($query);
return Response::json(['success' => true, 'affected' => $affected]);
}
} catch (\Throwable $th) {
return ResponseHelper::returnError($th->getMessage());
}
}
/**
* Toggle maintenance mode system-wide.
*/
public function toggleMaintenance(Request $request)
{
if (!$this->checkAccess() || !UserPermissions::isActionPermitted(0, UserActions::UltimateMaintenance)) {
return ResponseHelper::returnUnauthorized();
}
$enabled = (bool) $request->input('enabled');
Redis::set('system:maintenance_mode', $enabled ? 'true' : 'false');
return Response::json(['success' => true, 'maintenance_mode' => $enabled]);
}
/**
* Send a global message / broadcast.
*/
public function sendGlobalMessage(Request $request)
{
if (!$this->checkAccess() || !UserPermissions::isActionPermitted(0, UserActions::UltimateGlobalMessage)) {
return ResponseHelper::returnUnauthorized();
}
$message = $request->input('message');
$type = $request->input('type', 'info'); // info, success, warning, danger
if (empty($message)) {
Redis::del('system:global_message');
return Response::json(['success' => true, 'message' => 'Global message cleared']);
}
Redis::set('system:global_message', json_encode([
'text' => $message,
'type' => $type,
'timestamp' => time()
]));
return Response::json(['success' => true]);
}
/**
* Flush / Truncate specific tables.
*/
public function flushData(Request $request)
{
if (!$this->checkAccess() || !UserPermissions::isActionPermitted(0, UserActions::UltimateFlush)) {
return ResponseHelper::returnUnauthorized();
}
$target = $request->input('target');
try {
$affected = 0;
switch ($target) {
case 'transactions':
$affected = GlobalTransaction::count();
DB::statement('SET FOREIGN_KEY_CHECKS = 0');
GlobalTransaction::truncate();
DB::statement('SET FOREIGN_KEY_CHECKS = 1');
break;
case 'pos_sessions':
$affected = DB::table('pos_sessions')->count();
DB::table('pos_sessions')->truncate();
break;
case 'cache':
Redis::flushDB();
break;
case 'stores':
$affected = DB::table('str')->count();
DB::statement('SET FOREIGN_KEY_CHECKS = 0');
DB::table('prd_str')->truncate();
DB::table('store_managers')->truncate();
DB::table('str')->truncate();
DB::statement('SET FOREIGN_KEY_CHECKS = 1');
break;
case 'products':
$affected = DB::table('prd_items')->count();
DB::statement('SET FOREIGN_KEY_CHECKS = 0');
DB::table('prd_str')->truncate();
DB::table('prd_items')->truncate();
DB::statement('SET FOREIGN_KEY_CHECKS = 1');
break;
case 'cooperatives':
$affected = DB::table('organizations')->where('type', 'COOPERATIVE')->count();
DB::statement('SET FOREIGN_KEY_CHECKS = 0');
DB::table('cooperative_votes')->truncate();
DB::table('cooperative_resolutions')->truncate();
DB::table('cooperative_documents')->truncate();
DB::table('cooperative_members')->truncate();
DB::table('organizations')->where('type', 'COOPERATIVE')->delete();
DB::statement('SET FOREIGN_KEY_CHECKS = 1');
break;
case 'carts':
$affected = DB::table('carts')->count();
DB::statement('SET FOREIGN_KEY_CHECKS = 0');
DB::table('cart_items')->truncate();
DB::table('carts')->truncate();
DB::statement('SET FOREIGN_KEY_CHECKS = 1');
break;
case 'farmer_profiles':
$affected = DB::table('farmer_profiles')->count();
DB::statement('SET FOREIGN_KEY_CHECKS = 0');
DB::table('farmer_profiles')->truncate();
DB::statement('SET FOREIGN_KEY_CHECKS = 1');
break;
default:
return ResponseHelper::returnError('Invalid flush target');
}
return Response::json(['success' => true, 'message' => "Flushed $target successfully", 'affected' => $affected]);
} catch (\Throwable $th) {
return ResponseHelper::returnError($th->getMessage());
}
}
/**
* Trigger a test notification for a specific user.
*/
public function testNotification(Request $request)
{
if (!$this->checkAccess()) return ResponseHelper::returnUnauthorized();
$userHash = $request->input('user_hash');
$user = User::where('hashkey', $userHash)->first();
if (!$user) return ResponseHelper::returnError('User not found');
// Setting exec_command which SSEController picks up to notify client
$user->exec_command = 'toast:success:Test Notification from Ultimate Console: ' . date('H:i:s');
$user->save();
return Response::json(['success' => true]);
}
/**
* Batch management for various entities.
*/
public function batchManage(Request $request)
{
if (!$this->checkAccess() || !UserPermissions::isActionPermitted(0, UserActions::UltimateBatch)) {
return ResponseHelper::returnUnauthorized();
}
$action = $request->input('action');
$ids = $request->input('ids', []);
$data = $request->input('data', []);
if (empty($ids) && !in_array($action, ['cleanup_sessions'])) {
return ResponseHelper::returnError('No IDs provided');
}
try {
switch($action) {
case 'activate_users':
User::whereIn('id', $ids)->update(['active' => true]);
break;
case 'deactivate_users':
User::whereIn('id', $ids)->update(['active' => false]);
break;
case 'cleanup_sessions':
DB::table('pos_sessions')->where('status', 'VOIDED')->delete();
break;
case 'mass_transfer_points':
$amount = (float)($data['amount'] ?? 0);
if ($amount <= 0) return ResponseHelper::returnError('Invalid amount');
foreach ($ids as $id) {
GlobalTransaction::create([
'user_id' => $id,
'amount' => $amount,
'type' => 'REWARD',
'description' => 'Mass points adjustment via Ultimate Console',
'is_active' => true,
]);
}
break;
default:
return ResponseHelper::returnError('Invalid batch action');
}
return Response::json(['success' => true]);
} catch (\Throwable $th) {
return ResponseHelper::returnError($th->getMessage());
}
}
/**
* Run a system command (Artisan wrapper).
*/
public function runCommand(Request $request)
{
if (!$this->checkAccess()) return ResponseHelper::returnUnauthorized();
$command = $request->input('command');
if (empty($command)) return ResponseHelper::returnError('Command cannot be empty');
// Normalize command: strip 'php artisan ' if present
$command = preg_replace('/^php artisan\s+/', '', trim($command));
// Mapping for user-friendly commands
if ($command === 'reset-app all users') {
$command = 'app:reset-users';
}
if ($command === 'db seed') {
$command = 'db:seed';
}
// For security, only allow specific commands
$allowedCommands = [
'cache:clear', 'view:clear', 'config:clear', 'route:clear',
'migrate', 'migrate:rollback', 'migrate:fresh',
'db:seed', 'app:reset-users', 'optimize', 'optimize:clear'
];
$baseCommand = explode(' ', trim($command))[0];
if (!in_array($baseCommand, $allowedCommands)) {
return ResponseHelper::returnError("Command '{$baseCommand}' not allowed for security reasons.");
}
try {
// In Hyperf, running commands from HTTP request context is tricky.
// We'll use shell_exec in this local environment demo as a fallback.
$output = shell_exec("php artisan $command 2>&1");
return Response::json(['success' => true, 'output' => $output]);
} catch (\Throwable $th) {
return ResponseHelper::returnError($th->getMessage());
}
}
/**
* Run `php artisan migrate --force` (non-interactive).
*/
public function runMigrate(Request $request)
{
if (!$this->checkAccess()) return ResponseHelper::returnUnauthorized();
try {
$output = shell_exec('cd ' . escapeshellarg(BASE_PATH) . ' && php artisan migrate --force 2>&1');
return Response::json(['success' => true, 'output' => $output]);
} catch (\Throwable $th) {
return ResponseHelper::returnError($th->getMessage());
}
}
/**
* Download a full database backup.
* Puts system in maintenance mode during the process.
*/
public function downloadBackup()
{
if (!$this->checkAccess()) return ResponseHelper::returnUnauthorized();
// 1. Enable maintenance mode & Notify
Redis::set('system:maintenance_mode', 'true');
Redis::set('system:global_message', json_encode(['text' => 'System backup in progress. Transactions temporarily disabled.', 'type' => 'warning']));
try {
$filename = 'backup_' . date('Y-m-d_H-i-s') . '.sql';
$path = BASE_PATH . '/storage/app/backups/' . $filename;
if (!is_dir(dirname($path))) {
mkdir(dirname($path), 0755, true);
}
$dbHost = env('DB_HOST', '127.0.0.1');
$dbPort = env('DB_PORT', '3306');
$dbName = env('DB_DATABASE', 'bukid');
$dbUser = env('DB_USERNAME', 'root');
$dbPass = env('DB_PASSWORD', '');
$dump = new \Ifsnop\Mysqldump\Mysqldump(
"mysql:host={$dbHost};port={$dbPort};dbname={$dbName}",
$dbUser,
$dbPass,
[
'add-drop-table' => true,
'exclude-tables' => ['db_backups'] // Exclude the backups table
]
);
$dump->start($path);
if (!file_exists($path) || filesize($path) === 0) {
throw new \Exception('Backup file was not created or is empty.');
}
// Compress into 7z Ultra
$sevenZFilename = 'backup_' . date('Y-m-d_H-i-s') . '.7z';
$sevenZPath = BASE_PATH . '/storage/app/backups/' . $sevenZFilename;
// -mx=9 for Ultra compression
$path_escaped = escapeshellarg($path);
$sevenZPath_escaped = escapeshellarg($sevenZPath);
$command = "7z a -t7z -m0=lzma2 -mx=9 {$sevenZPath_escaped} {$path_escaped} 2>&1";
shell_exec($command);
if (!file_exists($sevenZPath)) {
throw new \Exception('Failed to create 7z archive.');
}
// Save to database
$fileContentRaw = file_get_contents($sevenZPath);
$fileHash = hash('sha256', $fileContentRaw);
$fileContent = new FileContent();
$fileContent->filehash = $fileHash;
$fileContent->titlename = $sevenZFilename;
$fileContent->description = 'System database backup';
$fileContent->size_in_bytes = filesize($sevenZPath);
$fileContent->content = base64_encode($fileContentRaw);
$fileContent->mimetype = 'application/x-7z-compressed';
$fileContent->created_by = Auth::id();
$fileContent->updated_by = Auth::id();
$fileContent->save();
$dbBackup = new DbBackup();
$dbBackup->file_content_hashkey = $fileContent->hashkey;
$dbBackup->filename = $sevenZFilename;
$dbBackup->size_in_bytes = filesize($sevenZPath);
$dbBackup->created_by = Auth::id();
$dbBackup->updated_by = Auth::id();
$dbBackup->save();
// Clean up the temporary files from filesystem
@unlink($path);
@unlink($sevenZPath);
// 2. Disable maintenance mode & Clear Notify
Redis::set('system:maintenance_mode', 'false');
Redis::del('system:global_message');
// 3. Return the binary content for download
return Response::make($fileContentRaw, 200, [
'Content-Type' => 'application/x-7z-compressed',
'Content-Disposition' => 'attachment; filename="' . $sevenZFilename . '"',
'Content-Length' => strlen($fileContentRaw),
]);
} catch (\Throwable $th) {
Redis::set('system:maintenance_mode', 'false');
return ResponseHelper::returnError($th->getMessage());
}
}
/**
* Get recently created backups.
*/
public function getBackups()
{
if (!$this->checkAccess()) return ResponseHelper::returnUnauthorized();
$backups = DbBackup::with(['creator'])
->orderBy('created_at', 'desc')
->limit(50)
->get();
return Response::json(['success' => true, 'data' => $backups]);
}
/**
* Rename a specific backup.
*/
public function renameBackup(Request $request)
{
if (!$this->checkAccess()) return ResponseHelper::returnUnauthorized();
$hash = $request->input('hash');
$newName = $request->input('name');
if (empty($newName)) return ResponseHelper::returnError('Name cannot be empty');
$backup = DbBackup::where('hashkey', $hash)->first();
if (!$backup) return ResponseHelper::returnError('Backup not found');
$backup->name = $newName;
$backup->save();
return Response::json(['success' => true]);
}
/**
* Delete a specific backup.
*/
public function deleteBackup(Request $request)
{
if (!$this->checkAccess()) return ResponseHelper::returnUnauthorized();
$hash = $request->input('hash');
$backup = DbBackup::where('hashkey', $hash)->first();
if (!$backup) return ResponseHelper::returnError('Backup not found');
// Delete associated file content
FileContent::where('hashkey', $backup->file_content_hashkey)->delete();
// Delete backup record
$backup->delete();
return Response::json(['success' => true]);
}
/**
* Download a specific backup from the database.
*/
public function downloadBackupByHash(Request $request)
{
if (!$this->checkAccess()) return ResponseHelper::returnUnauthorized();
$hash = $request->input('hash');
$backup = DbBackup::where('hashkey', $hash)->first();
if (!$backup) return ResponseHelper::returnError('Backup not found');
$fileContent = FileContent::where('hashkey', $backup->file_content_hashkey)->first();
if (!$fileContent) return ResponseHelper::returnError('File content not found');
$content = base64_decode($fileContent->content);
return Response::make($content, 200, [
'Content-Type' => $fileContent->mimetype,
'Content-Disposition' => 'attachment; filename="' . $backup->filename . '"',
'Content-Length' => strlen($content),
]);
}
/**
* Get system-wide logs from file and database.
*/
public function getSystemLogs(Request $request)
{
if (!$this->checkAccess()) return ResponseHelper::returnUnauthorized();
$type = $request->input('type', 'database');
if ($type === 'file') {
$logPath = BASE_PATH . '/storage/logs/hypervel.log';
if (!file_exists($logPath)) {
return Response::json(['success' => true, 'data' => 'No file logs found.']);
}
$logs = shell_exec("tail -n 1000 " . escapeshellarg($logPath));
return Response::json(['success' => true, 'data' => $logs]);
}
// Database logs (audit)
if ($type === 'audit') {
$logs = DB::table('table_logs')->orderBy('id', 'desc')->limit(500)->get();
return Response::json(['success' => true, 'data' => $logs]);
}
// Database logs (system)
$logs = DB::table('logs')->orderBy('uid', 'desc')->limit(500)->get();
return Response::json(['success' => true, 'data' => $logs]);
}
}

View File

@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Market;
use App\Http\Controllers\Helpers\ResponseHelper;
use App\Models\Market\UserInfo;
use App\Models\User;
use Hypervel\Http\Request;
use Hypervel\Support\Facades\Auth;
use App\Http\Controllers\Helpers\Permissions\UserPermissions;
use App\Enums\UserActions;
class UserInfoController
{
public function getUserInfo(Request $request, string $hashkey)
{
$targetUser = User::where('hashkey', $hashkey)->first();
if (!$targetUser) {
return ResponseHelper::returnError('User not found', 404);
}
$currentUser = Auth::user();
if (!$currentUser) {
return ResponseHelper::returnUnauthorized();
}
// Check permission: can view self or has ViewUserInfo permission for others
if ($currentUser->id !== $targetUser->id && !UserPermissions::isActionPermitted($currentUser->acct_type, UserActions::ViewUserInfo)) {
return ResponseHelper::returnUnauthorized();
}
$userInfo = $targetUser->userInfo;
if (!$userInfo) {
// Lazy create if it doesn't exist (should have been backfilled but just in case)
$userInfo = UserInfo::create([
'user_id' => $targetUser->id,
'fullname' => $targetUser->fullname ?? $targetUser->name,
'email' => $targetUser->email,
'mobile' => $targetUser->mobile_number,
'is_active' => true,
]);
}
return response()->json([
'success' => true,
'data' => $userInfo
]);
}
public function updateUserInfo(Request $request, string $hashkey)
{
$targetUser = User::where('hashkey', $hashkey)->first();
if (!$targetUser) {
return ResponseHelper::returnError('User not found', 404);
}
$currentUser = Auth::user();
if (!$currentUser) {
return ResponseHelper::returnUnauthorized();
}
// Check permission: can manage self or has ManageUserInfo permission for others
if ($currentUser->id !== $targetUser->id && !UserPermissions::isActionPermitted($currentUser->acct_type, UserActions::ManageUserInfo)) {
return ResponseHelper::returnUnauthorized();
}
$userInfo = $targetUser->userInfo;
if (!$userInfo) {
$userInfo = new UserInfo(['user_id' => $targetUser->id]);
}
$validated = $request->validate([
'firstname' => 'nullable|string|max:255',
'middlename' => 'nullable|string|max:255',
'lastname' => 'nullable|string|max:255',
'suffix' => 'nullable|string|max:50',
'gender' => 'nullable|string|max:50',
'dob' => 'nullable|date',
'priority_sector' => 'nullable|string|max:255',
'messenger_id' => 'nullable|string|max:255',
'viber_number' => 'nullable|string|max:255',
'tiktok_username' => 'nullable|string|max:255',
'region' => 'nullable|string|max:255',
'province' => 'nullable|string|max:255',
'city' => 'nullable|string|max:255',
'barangay' => 'nullable|string|max:255',
'civil_status' => 'nullable|string|max:100',
'children_count' => 'nullable|integer',
'dependent_count' => 'nullable|integer',
'education_level' => 'nullable|string|max:255',
'course' => 'nullable|string|max:255',
'school' => 'nullable|string|max:255',
'year_last_attended' => 'nullable|string|max:50',
'livelihood_source' => 'nullable|string|max:255',
'last_company' => 'nullable|string|max:255',
'employer_name' => 'nullable|string|max:255',
'last_position' => 'nullable|string|max:255',
'occupation' => 'nullable|string|max:255',
'last_employment_year' => 'nullable|string|max:50',
'monthly_income' => 'nullable|numeric',
'tin' => 'nullable|string|max:100',
'philhealth_id' => 'nullable|string|max:100',
'gov_id' => 'nullable|string|max:100',
'id_type' => 'nullable|string|max:100',
'id_number' => 'nullable|string|max:100',
'beneficiary_type' => 'nullable|string|max:100',
'emergency_contact_name' => 'nullable|string|max:255',
'emergency_contact_address' => 'nullable|string|max:255',
'emergency_contact_phone' => 'nullable|string|max:50',
'emergency_contact_relation' => 'nullable|string|max:100',
'emergency_contact_user_id' => 'nullable|integer',
'fullname' => 'nullable|string|max:255',
'landline' => 'nullable|string|max:20',
'mobile' => 'nullable|string|max:20',
'email' => 'nullable|email|max:255',
'alt_email' => 'nullable|email|max:255',
'alt_landline' => 'nullable|string|max:20',
'alt_mobile' => 'nullable|string|max:20',
'facebook_url' => 'nullable|url|max:255',
'bank_details' => 'nullable|array',
'bank_account_no' => 'nullable|string|max:100',
'addresses' => 'nullable|array',
'other_details' => 'nullable|array',
]);
// Logic to automatically populate emergency_contact_user_id if phone matches a registered user
if (!empty($validated['emergency_contact_phone'])) {
$matchedUser = User::where('mobile_number', $validated['emergency_contact_phone'])->first();
if ($matchedUser) {
$validated['emergency_contact_user_id'] = $matchedUser->id;
}
}
$userInfo->fill($validated);
if ($userInfo->save()) {
// Also update core user fields if they match
if (isset($validated['fullname'])) $targetUser->fullname = $validated['fullname'];
if (isset($validated['email'])) $targetUser->email = $validated['email'];
if (isset($validated['mobile'])) $targetUser->mobile_number = $validated['mobile'];
$targetUser->save();
return ResponseHelper::returnSuccessResponse($userInfo, $userInfo->hashkey, 'User info updated');
}
return ResponseHelper::returnError('Failed to update user info');
}
public function searchEmergencyContact(Request $request)
{
$query = $request->input('q');
if (empty($query)) {
return response()->json(['success' => true, 'data' => []]);
}
$users = User::where('name', 'like', "%$query%")
->orWhere('fullname', 'like', "%$query%")
->orWhere('mobile_number', 'like', "%$query%")
->limit(10)
->get(['id', 'name', 'fullname', 'mobile_number', 'hashkey']);
return response()->json([
'success' => true,
'data' => $users
]);
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Market;
use App\Http\Controllers\Helpers\ResponseHelper;
use App\Models\User;
use Hypervel\Http\Request;
use Hypervel\Support\Facades\Auth;
class UserSettingsController
{
/**
* Get the current user's settings.
*/
public function getSettings()
{
$user = Auth::user();
if (!$user) {
return ResponseHelper::returnUnauthorized();
}
return response()->json($user->settings ?? []);
}
/**
* Update the current user's settings.
*/
public function updateSettings(Request $request)
{
$user = Auth::user();
if (!$user) {
return ResponseHelper::returnUnauthorized();
}
$newSettings = $request->all();
$currentSettings = $user->settings ?? [];
// Merge new settings into current settings
$updatedSettings = array_merge($currentSettings, $newSettings);
// Save to database
$user->settings = $updatedSettings;
$user->save();
return response()->json([
'success' => true,
'settings' => $user->settings
]);
}
}