migrate to gtea from bistbucket
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Customer\Http\Controllers;
|
||||
|
||||
use App\Helper\RestaurantHelper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Traits\Authenticatable;
|
||||
use Carbon\Carbon;
|
||||
use Exception;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Modules\Customer\Http\Requests\Auth\CustomerLoginRequest;
|
||||
use Modules\Customer\Http\Requests\Auth\ProfileUpdateRequest;
|
||||
use Modules\Customer\Models\OtpVerification;
|
||||
use Modules\Restaurant\Models\Customer;
|
||||
|
||||
class CustomerAuthController extends Controller
|
||||
{
|
||||
use Authenticatable;
|
||||
|
||||
/**
|
||||
* Step 1: Send OTP to phone (Create customer if not exists)
|
||||
*/
|
||||
public function customerLogin(CustomerLoginRequest $request)
|
||||
{
|
||||
$phone = $request->phone;
|
||||
$restaurantId = $this->getRestaurantIdFromHeader($request);
|
||||
if ($restaurantId instanceof JsonResponse) {
|
||||
return $restaurantId;
|
||||
}
|
||||
|
||||
// Check if customer already exists
|
||||
$customer = Customer::where('restaurant_id', $restaurantId)
|
||||
->where('phone', $phone)
|
||||
->first();
|
||||
|
||||
// If customer not exists, create one
|
||||
if (! $customer) {
|
||||
$customer = Customer::create([
|
||||
'name' => 'Customer '.substr($phone, -4),
|
||||
'phone' => $phone,
|
||||
'password' => Hash::make($phone), // temporary password
|
||||
'restaurant_id' => $restaurantId,
|
||||
]);
|
||||
}
|
||||
|
||||
// Generate OTP
|
||||
$otp = '1234'; // rand(100000, 999999);
|
||||
$expiryTime = now()->addMinutes(5);
|
||||
|
||||
// Save or update OTP record
|
||||
OtpVerification::updateOrCreate(
|
||||
[
|
||||
'restaurant_id' => $restaurantId,
|
||||
'phone' => $phone,
|
||||
],
|
||||
[
|
||||
'otp' => $otp,
|
||||
'expires_at' => $expiryTime,
|
||||
'updated_at' => now(),
|
||||
]
|
||||
);
|
||||
|
||||
// TODO: Send OTP via SMS Gateway (mock for now)
|
||||
// Muthofun::send($phone, "Your OTP is: $otp");
|
||||
|
||||
return $this->ResponseSuccess([
|
||||
'phone' => $phone,
|
||||
'otp' => $otp, // ⚠️ remove in production
|
||||
], 'OTP sent successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2: Verify OTP and Login
|
||||
*/
|
||||
public function customerOtpVerify(Request $request)
|
||||
{
|
||||
$restaurantId = $this->getRestaurantIdFromHeader($request);
|
||||
if ($restaurantId instanceof JsonResponse) {
|
||||
return $restaurantId;
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'phone' => 'required|numeric|exists:customers,phone',
|
||||
'otp' => 'required',
|
||||
]);
|
||||
|
||||
$phone = $request->phone;
|
||||
$otp = $request->otp;
|
||||
|
||||
// Fetch OTP record
|
||||
$otpRecord = OtpVerification::where('restaurant_id', $restaurantId)
|
||||
->where('phone', $phone)
|
||||
->first();
|
||||
if (! $otpRecord) {
|
||||
return $this->ResponseError([], 'Invalid phone or OTP expired.', 400);
|
||||
}
|
||||
|
||||
// Check expiry
|
||||
if (now()->greaterThan($otpRecord->expires_at)) {
|
||||
$otpRecord->update(['otp' => null]);
|
||||
|
||||
return $this->ResponseError([], 'OTP expired. Please request a new one.', 400);
|
||||
}
|
||||
|
||||
// Check OTP validity
|
||||
if ($otpRecord->otp != $otp) {
|
||||
return $this->ResponseError([], 'Invalid OTP.', 400);
|
||||
}
|
||||
|
||||
// OTP valid → Find customer
|
||||
$customer = Customer::where('restaurant_id', $restaurantId)
|
||||
->where('phone', $phone)
|
||||
->first();
|
||||
|
||||
if (! $customer) {
|
||||
return $this->ResponseError([], 'Customer not found.', 404);
|
||||
}
|
||||
|
||||
// Clear OTP after success
|
||||
$otpRecord->update(['otp' => null]);
|
||||
|
||||
// Create API token
|
||||
$tokenResult = $customer->createToken('Customer Access Token');
|
||||
|
||||
return $this->ResponseSuccess([
|
||||
'customer' => $customer,
|
||||
'access_token' => $tokenResult->accessToken,
|
||||
'expires_at' => $tokenResult->token->expires_at,
|
||||
], 'Login successful.');
|
||||
}
|
||||
|
||||
public function customerOtpSent(Request $request)
|
||||
{
|
||||
$restaurantId = $this->getRestaurantIdFromHeader($request);
|
||||
if ($restaurantId instanceof JsonResponse) {
|
||||
return $restaurantId;
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'phone' => 'required|numeric|exists:customers,phone',
|
||||
]);
|
||||
|
||||
$phone = $request->phone;
|
||||
// TODO
|
||||
$expiryTime = now()->addMinutes(15); // OTP expiry = 2 minutes
|
||||
$otp = rand(100000, 999999); // Generate random 6-digit OTP
|
||||
|
||||
// Check if an OTP record already exists for this phone
|
||||
$otpRecord = OtpVerification::where('restaurant_id', $restaurantId)
|
||||
->where('phone', $phone)
|
||||
->first();
|
||||
|
||||
if ($otpRecord) {
|
||||
// Check if existing OTP is still valid (not expired)
|
||||
if (now()->lessThan($otpRecord->expires_at)) {
|
||||
$remainingSeconds = now()->diffInSeconds($otpRecord->expires_at);
|
||||
|
||||
return $this->ResponseError([
|
||||
'remaining_seconds' => $remainingSeconds,
|
||||
], "An OTP has already been sent. Please wait {$remainingSeconds} seconds before requesting a new one.", 429);
|
||||
}
|
||||
|
||||
// OTP expired → generate and resend a new one
|
||||
$otpRecord->update([
|
||||
'otp' => $otp,
|
||||
'expires_at' => $expiryTime,
|
||||
]);
|
||||
} else {
|
||||
// No record → create a new one
|
||||
OtpVerification::create([
|
||||
'phone' => $phone,
|
||||
'otp' => $otp,
|
||||
'expires_at' => $expiryTime,
|
||||
]);
|
||||
}
|
||||
|
||||
// TODO: Replace with your SMS sending logic
|
||||
// Example: Muthofun::send($phone, "Your OTP is: $otp");
|
||||
|
||||
return $this->ResponseSuccess([
|
||||
'phone' => $phone,
|
||||
'otp' => $otp, // ⚠️ Remove in production
|
||||
'expires_at' => $expiryTime->toDateTimeString(),
|
||||
], 'OTP sent successfully.');
|
||||
}
|
||||
|
||||
public function customerProfile(Request $request)
|
||||
{
|
||||
try {
|
||||
$customer = Customer::where('id', Auth::id())->first();
|
||||
|
||||
if ($customer) {
|
||||
$permissions = $customer->role?->permissions->pluck('name')->toArray() ?? []; // Convert permissions to an array
|
||||
|
||||
$customer = [
|
||||
'id' => $customer->id,
|
||||
'name' => $customer->first_name.' '.$customer->last_name,
|
||||
'email' => $customer->email,
|
||||
'phone' => $customer->phone,
|
||||
'image' => $customer->avatar,
|
||||
'role' => $customer->role?->name ?? 'no-role-assign',
|
||||
'status' => $customer->status,
|
||||
'permissions' => $permissions,
|
||||
];
|
||||
|
||||
return $this->responseSuccess($customer, 'Profile fetched successfully.');
|
||||
} else {
|
||||
return $this->responseError([], 'Profile not found.');
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
return $this->responseError([], 'An error occurred while fetching the profile.');
|
||||
}
|
||||
}
|
||||
|
||||
public function customerUpdate(ProfileUpdateRequest $request)
|
||||
{
|
||||
$customer = Auth::guard('customer')->user();
|
||||
|
||||
// Get validated data
|
||||
$data = $request->validated();
|
||||
|
||||
// Avatar upload (first time + update)
|
||||
if ($request->hasFile('avatar')) {
|
||||
$data['avatar'] = fileUploader(
|
||||
'customers/',
|
||||
'png',
|
||||
$request->file('avatar'),
|
||||
$customer->avatar // old image (null for first upload)
|
||||
);
|
||||
}
|
||||
|
||||
// Fill customer data
|
||||
$customer->fill($data);
|
||||
|
||||
// Reset email verification if email changed
|
||||
if ($customer->isDirty('email')) {
|
||||
$customer->email_verified_at = null;
|
||||
}
|
||||
|
||||
$customer->save();
|
||||
|
||||
return $this->responseSuccess(
|
||||
$customer->fresh(),
|
||||
'Profile Update Successfully Done.'
|
||||
);
|
||||
}
|
||||
|
||||
public function changePassword(Request $request)
|
||||
{
|
||||
$validated = $request->validateWithBag('updatePassword', [
|
||||
'current_password' => ['required', 'current_password'],
|
||||
'password' => ['required', 'confirmed'],
|
||||
]);
|
||||
|
||||
$request->customer()->update([
|
||||
'password' => Hash::make($validated['password']),
|
||||
]);
|
||||
|
||||
return $this->ResponseSuccess([], 'Password Changes Successfully.');
|
||||
}
|
||||
|
||||
public function accountDeleted(Request $request)
|
||||
{
|
||||
$request->validateWithBag('updatePassword', [
|
||||
'current_password' => ['required', 'current_password'],
|
||||
]);
|
||||
|
||||
$request->customer()->update([
|
||||
'deleted_at' => Carbon::now(),
|
||||
]);
|
||||
|
||||
return $this->ResponseSuccess([], 'Account Delete successfully.');
|
||||
}
|
||||
|
||||
public function customerLogout(Request $request)
|
||||
{
|
||||
try {
|
||||
$result = $request->customer()->token()->revoke();
|
||||
if ($result) {
|
||||
return $this->ResponseSuccess([], 'Logout Success');
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
return $this->ResponseSuccess([], 'Logout Failed');
|
||||
}
|
||||
}
|
||||
|
||||
private function getRestaurantIdFromHeader(Request $request)
|
||||
{
|
||||
$domain = $request->header('X-Domain'); // Custom header key (Change if needed)
|
||||
if (! $domain) {
|
||||
return $this->responseError([], 'Domain header is missing.', 400);
|
||||
}
|
||||
|
||||
$restaurantResponse = RestaurantHelper::getRestaurantIdByDomain($domain);
|
||||
if (! $restaurantResponse['status']) {
|
||||
return $this->responseError([], $restaurantResponse['error'], 404);
|
||||
}
|
||||
|
||||
return $restaurantResponse['restaurant_id'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Customer\Http\Controllers;
|
||||
|
||||
use App\Enum\ActionStatus;
|
||||
use App\Enum\OrderStatus;
|
||||
use App\Helper\OrderHelper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\OrderInvoiceMail;
|
||||
use App\Services\Firebase\FirebaseService;
|
||||
use App\Traits\Authenticatable;
|
||||
use App\Traits\RequestSanitizerTrait;
|
||||
use App\Traits\Trackable;
|
||||
use Carbon\Carbon;
|
||||
use Exception;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Modules\Booking\Http\Requests\Booking\CustomerBookingStoreRequest;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Booking\Models\BookingItem;
|
||||
use Modules\Restaurant\Http\Requests\Order\CustomerOrderStoreRequest;
|
||||
use Modules\Restaurant\Http\Requests\Order\CustomerOrderUpdateRequest;
|
||||
use Modules\Restaurant\Http\Resources\OrderResource;
|
||||
use Modules\Restaurant\Models\Customer;
|
||||
use Modules\Restaurant\Models\Order;
|
||||
|
||||
class CustomerController extends Controller
|
||||
{
|
||||
use Authenticatable, RequestSanitizerTrait, Trackable;
|
||||
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
try {
|
||||
$query = Order::query()
|
||||
->select('orders.*')
|
||||
->leftJoin('customers', 'orders.customer_id', '=', 'customers.id')
|
||||
->leftJoin('users as waiters', 'orders.waiter_id', '=', 'waiters.id')
|
||||
->with([
|
||||
'items.variation',
|
||||
'items.food',
|
||||
'customer',
|
||||
'table',
|
||||
'paymentMethod',
|
||||
'waiter',
|
||||
]);
|
||||
|
||||
/**
|
||||
* 🔍 GLOBAL SEARCH
|
||||
*/
|
||||
if ($search = request('search')) {
|
||||
$search = strtolower(trim($search));
|
||||
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->whereRaw('LOWER(orders.order_number) LIKE ?', ["%{$search}%"])
|
||||
->orWhereRaw('LOWER(orders.order_type) LIKE ?', ["%{$search}%"])
|
||||
->orWhereRaw('LOWER(orders.status) LIKE ?', ["%{$search}%"])
|
||||
->orWhereRaw('LOWER(customers.name) LIKE ?', ["%{$search}%"])
|
||||
->orWhereRaw('LOWER(customers.phone) LIKE ?', ["%{$search}%"])
|
||||
->orWhereRaw('LOWER(waiters.first_name) LIKE ?', ["%{$search}%"]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 📌 STATUS FILTER
|
||||
*/
|
||||
if ($status = request('status')) {
|
||||
$query->where('orders.status', $status);
|
||||
}
|
||||
|
||||
/**
|
||||
* 🍽 ORDER TYPE FILTER
|
||||
*/
|
||||
if ($orderType = request('order_type')) {
|
||||
$query->where('orders.order_type', $orderType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 👨🍳 WAITER FILTER
|
||||
*/
|
||||
if ($waiterId = request('waiter_id')) {
|
||||
$query->where('orders.waiter_id', $waiterId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 👤 CUSTOMER FILTER
|
||||
*/
|
||||
if ($customerId = request('customer_id')) {
|
||||
$query->where('orders.customer_id', $customerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 📆 DATE RANGE FILTER
|
||||
*/
|
||||
if (request('from_date') && request('to_date')) {
|
||||
$query->whereBetween('orders.created_at', [
|
||||
request('from_date').' 00:00:00',
|
||||
request('to_date').' 23:59:59',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔃 SORTING (SAFE WHITELIST)
|
||||
*/
|
||||
$allowedSorts = [
|
||||
'created_at',
|
||||
'order_number',
|
||||
'status',
|
||||
'order_type',
|
||||
'grand_total',
|
||||
];
|
||||
|
||||
$sortBy = request('sort_by', 'created_at');
|
||||
$sortOrder = request('sort_order', 'desc');
|
||||
|
||||
if (! in_array($sortBy, $allowedSorts)) {
|
||||
$sortBy = 'created_at';
|
||||
}
|
||||
|
||||
if (! in_array($sortOrder, ['asc', 'desc'])) {
|
||||
$sortOrder = 'desc';
|
||||
}
|
||||
|
||||
/**
|
||||
* 📄 PAGINATION
|
||||
*/
|
||||
$orders = $query
|
||||
->orderBy("orders.$sortBy", $sortOrder)
|
||||
->paginate(request('perPage', 10));
|
||||
|
||||
/**
|
||||
* 📦 APPLY RESOURCE (KEEP PAGINATION STRUCTURE)
|
||||
*/
|
||||
$orders->getCollection()->transform(function ($order) {
|
||||
return new OrderResource($order);
|
||||
});
|
||||
|
||||
return $this->responseSuccess([
|
||||
$orders,
|
||||
], 'Order fetch successfully.');
|
||||
} catch (\Throwable $e) {
|
||||
return $this->responseError([], 'Order fetch failed: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function store(CustomerOrderStoreRequest $request, FirebaseService $firebase): JsonResponse
|
||||
{
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
$restaurantId = $this->getCurrentRestaurantId();
|
||||
$orderNumber = OrderHelper::generateOrderNumber($restaurantId);
|
||||
|
||||
// 🔹 Calculate totals
|
||||
$subtotal = collect($request->items)->sum(fn ($i) => $i['quantity'] * $i['price']);
|
||||
$grandTotal = $subtotal - ($request->discount ?? 0) + ($request->tax ?? 0);
|
||||
|
||||
// 🔹 Create Order
|
||||
$order = Order::create([
|
||||
'restaurant_id' => $restaurantId,
|
||||
'order_number' => $orderNumber,
|
||||
'order_type' => $request->order_type,
|
||||
'status' => 'pending',
|
||||
'table_id' => $request->table_id,
|
||||
// 'waiter_id' => $request->waiter_id,
|
||||
'customer_id' => getUserId(),
|
||||
'payment_method_id' => $request->payment_method_id,
|
||||
'payment_status' => $request->payment_status ?? false,
|
||||
'subtotal' => $subtotal,
|
||||
'discount' => $request->discount ?? 0,
|
||||
'tax' => $request->tax ?? 0,
|
||||
'grand_total' => $grandTotal,
|
||||
'order_date' => Carbon::now(),
|
||||
'added_by' => getUserId(),
|
||||
]);
|
||||
|
||||
// 🔹 Create Order Items
|
||||
foreach ($request->items as $item) {
|
||||
$orderItem = $order->items()->create([
|
||||
'restaurant_id' => $restaurantId,
|
||||
'food_item_id' => $item['food_item_id'],
|
||||
'food_variant_id' => $item['food_variant_id'] ?? null,
|
||||
'quantity' => $item['quantity'],
|
||||
'price' => $item['price'],
|
||||
'total' => $item['quantity'] * $item['price'],
|
||||
'addons' => isset($item['addons']) ? json_encode($item['addons']) : null,
|
||||
]);
|
||||
|
||||
/**
|
||||
* 🔽 Ingredient deduction (Commented out for reference)
|
||||
*
|
||||
* foreach ($item['ingredients'] ?? [] as $ingredient) {
|
||||
* $ingredientModel = Ingredient::find($ingredient['id']);
|
||||
* if ($ingredientModel) {
|
||||
* $deductQty = $ingredient['quantity_required'] * $item['quantity'];
|
||||
* $ingredientModel->decrement('available_quantity', $deductQty);
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
}
|
||||
|
||||
$customer = auth()->user();
|
||||
// Action Log info
|
||||
$this->trackAction(ActionStatus::OrderCreate, Customer::class, $customer->id, $order);
|
||||
|
||||
DB::commit();
|
||||
|
||||
// Push Notification
|
||||
if ($customer?->fcm_token) {
|
||||
$result = $firebase->sendNotification(
|
||||
$customer->fcm_token,
|
||||
'Order Confirmed',
|
||||
"Your order #{$order->id} has been confirmed!",
|
||||
['order_id' => $order->id]
|
||||
);
|
||||
|
||||
if ($result === true) {
|
||||
return response()->json(['message' => 'Notification sent successfully']);
|
||||
} else {
|
||||
return response()->json(['message' => 'Failed to send notification', 'error' => $result], 500);
|
||||
}
|
||||
}
|
||||
|
||||
// Directly send email
|
||||
if ($order->customer?->email) {
|
||||
try {
|
||||
$result = shop_mailer_send($order->restaurant_id, $order->customer?->email, new OrderInvoiceMail($order));
|
||||
if (! $result['status']) {
|
||||
Log::warning("Order invoice email not sent for shop {$order->restaurant_id}: ".$result['message']);
|
||||
}
|
||||
Mail::to($order->customer?->email)->send(new OrderInvoiceMail($order));
|
||||
} catch (Exception $ex) {
|
||||
Log::error("Invoice email failed for order {$order->id}: ".$ex->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $this->responseSuccess([
|
||||
'order' => $order->load('items'),
|
||||
], 'Order created successfully.');
|
||||
} catch (Exception $e) {
|
||||
DB::rollBack();
|
||||
|
||||
return $this->responseError([], 'Order creation failed: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$order = Order::with([
|
||||
'items.food', // If you have relation: OrderItem → Food
|
||||
'customer',
|
||||
'table',
|
||||
'paymentMethod',
|
||||
])->findOrFail($id);
|
||||
|
||||
return $this->responseSuccess($order, 'Order has been fetched successfully.');
|
||||
} catch (Exception $e) {
|
||||
return $this->responseError([], $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function update(CustomerOrderUpdateRequest $request, int $id): JsonResponse
|
||||
{
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
$order = Order::with('items')->findOrFail($id);
|
||||
|
||||
// Recalculate total
|
||||
$totalAmount = collect($request->items)->sum(function ($item) {
|
||||
return $item['quantity'] * $item['price'];
|
||||
});
|
||||
|
||||
// Update order (order_number stays untouched)
|
||||
$order->update([
|
||||
'order_type' => $request->order_type,
|
||||
// 'status' => $request->status,
|
||||
'table_id' => $request->table_id,
|
||||
'payment_method_id' => $request->payment_method_id,
|
||||
'payment_status' => $request->payment_status ?? false,
|
||||
'waiter_id' => $request->waiter_id,
|
||||
// 'customer_id' => $request->customer_id,
|
||||
'grand_total' => $totalAmount,
|
||||
'updated_by' => getUserId(),
|
||||
]);
|
||||
|
||||
$existingItemIds = $order->items->pluck('id')->toArray();
|
||||
$requestItemIds = collect($request->items)->pluck('id')->filter()->toArray();
|
||||
|
||||
// 1️⃣ Remove items that are not in request anymore
|
||||
$itemsToDelete = array_diff($existingItemIds, $requestItemIds);
|
||||
if (! empty($itemsToDelete)) {
|
||||
$order->items()->whereIn('id', $itemsToDelete)->delete();
|
||||
}
|
||||
|
||||
// 2️⃣ Add or update items
|
||||
foreach ($request->items as $item) {
|
||||
if (isset($item['id']) && in_array($item['id'], $existingItemIds)) {
|
||||
// Update existing item
|
||||
$order->items()->where('id', $item['id'])->update([
|
||||
'restaurant_id' => $order->restaurant_id,
|
||||
'food_item_id' => $item['food_item_id'],
|
||||
'food_variant_id' => $item['food_variant_id'] ?? null,
|
||||
'quantity' => $item['quantity'],
|
||||
'price' => $item['price'],
|
||||
'total' => $item['quantity'] * $item['price'],
|
||||
]);
|
||||
} else {
|
||||
// Add new item
|
||||
$order->items()->create([
|
||||
'restaurant_id' => $order->restaurant_id,
|
||||
'food_item_id' => $item['food_item_id'],
|
||||
'food_variant_id' => $item['food_variant_id'] ?? null,
|
||||
'quantity' => $item['quantity'],
|
||||
'price' => $item['price'],
|
||||
'total' => $item['quantity'] * $item['price'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
return $this->responseSuccess($order->load('items'), 'Order has been updated successfully.');
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
|
||||
return $this->responseError([], 'Failed to update order: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
$order = Order::with('items')->findOrFail($id);
|
||||
|
||||
// Delete related order items
|
||||
foreach ($order->items as $item) {
|
||||
$item->delete();
|
||||
}
|
||||
|
||||
// Delete the order
|
||||
$order->delete();
|
||||
|
||||
DB::commit();
|
||||
|
||||
return $this->responseSuccess([], 'Order and related items have been deleted successfully.');
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
|
||||
return $this->responseError([], 'Failed to delete order: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function cancel(Request $request, int $orderId, FirebaseService $firebase): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'cancel_reason' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
$restaurantId = $this->getCurrentRestaurantId();
|
||||
|
||||
$order = Order::where('restaurant_id', $restaurantId)
|
||||
->where('id', $orderId)
|
||||
->first();
|
||||
|
||||
if (! $order) {
|
||||
return $this->responseError([], 'Order not found.');
|
||||
}
|
||||
|
||||
// 🚫 Block if already cancelled or completed
|
||||
if (in_array($order->status, ['cancelled', 'completed'])) {
|
||||
return $this->responseError([], "This order is already {$order->status} and cannot be cancelled.");
|
||||
}
|
||||
|
||||
// 🕒 Check time difference (5 minutes)
|
||||
$minutesSinceOrder = now()->diffInMinutes($order->created_at);
|
||||
$cancelLimit = env('ORDER_CANCEL_LIMIT', 5);
|
||||
if ($minutesSinceOrder > $cancelLimit) {
|
||||
return $this->responseError([], "You can only cancel an order within {$cancelLimit} minutes of placement.");
|
||||
}
|
||||
|
||||
// 🔹 Update order cancellation info
|
||||
$order->update([
|
||||
'status' => OrderStatus::CANCELLED,
|
||||
'cancelled_at' => now(),
|
||||
'cancel_reason' => $request->cancel_reason ?? 'No reason provided',
|
||||
'cancelled_by' => getUserId(),
|
||||
]);
|
||||
|
||||
$customer = auth()->user();
|
||||
// Action Log info
|
||||
$this->trackAction(ActionStatus::OrderCreate, Customer::class, $customer->id, $order);
|
||||
|
||||
DB::commit();
|
||||
|
||||
// TODO
|
||||
// Order Cancel Mail & Notification
|
||||
// // Push Notification
|
||||
// if ($customer?->fcm_token) {
|
||||
// $result = $firebase->sendNotification(
|
||||
// $customer->fcm_token,
|
||||
// 'Order Confirmed',
|
||||
// "Your order #{$order->id} has been confirmed!",
|
||||
// ['order_id' => $order->id]
|
||||
// );
|
||||
|
||||
// if ($result === true) {
|
||||
// return response()->json(['message' => 'Notification sent successfully']);
|
||||
// } else {
|
||||
// return response()->json(['message' => 'Failed to send notification', 'error' => $result], 500);
|
||||
// }
|
||||
// }
|
||||
|
||||
// // Directly send email
|
||||
// if ($order->customer?->email) {
|
||||
// try {
|
||||
// $result = shop_mailer_send($order->restaurant_id, $order->customer?->email, new OrderInvoiceMail($order));
|
||||
// if (! $result['status']) {
|
||||
// Log::warning("Order invoice email not sent for shop {$order->restaurant_id}: " . $result['message']);
|
||||
// }
|
||||
// Mail::to($order->customer?->email)->send(new OrderInvoiceMail($order));
|
||||
// } catch (Exception $ex) {
|
||||
// Log::error("Invoice email failed for order {$order->id}: " . $ex->getMessage());
|
||||
// }
|
||||
// }
|
||||
|
||||
return $this->responseSuccess([
|
||||
'order' => $order->fresh(),
|
||||
], 'Order cancelled successfully.');
|
||||
} catch (Exception $e) {
|
||||
DB::rollBack();
|
||||
|
||||
return $this->responseError([], 'Order cancellation failed: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function myBooking(): JsonResponse
|
||||
{
|
||||
try {
|
||||
$filters = request()->all();
|
||||
|
||||
$query = Booking::where('customer_id', getUserId())
|
||||
->with([
|
||||
'customer',
|
||||
'hotel',
|
||||
'bookingItems.room',
|
||||
]);
|
||||
|
||||
// -----------------------------
|
||||
// 🔍 SEARCHING
|
||||
// -----------------------------
|
||||
if (! empty($filters['search'])) {
|
||||
$search = $filters['search'];
|
||||
$query->whereHas('customer', function ($q) use ($search) {
|
||||
$q->where('name', 'LIKE', "%$search%")
|
||||
->orWhere('phone', 'LIKE', "%$search%");
|
||||
})->orWhere('id', $search);
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// 🎯 FILTERS
|
||||
// -----------------------------
|
||||
if (isset($filters['status'])) {
|
||||
$query->where('status', $filters['status']);
|
||||
}
|
||||
|
||||
if (! empty($filters['customer_id'])) {
|
||||
$query->where('customer_id', $filters['customer_id']);
|
||||
}
|
||||
|
||||
if (! empty($filters['hotel_id'])) {
|
||||
$query->where('hotel_id', $filters['hotel_id']);
|
||||
}
|
||||
|
||||
if (! empty($filters['check_in_from']) && ! empty($filters['check_in_to'])) {
|
||||
$query->whereBetween('check_in', [$filters['check_in_from'], $filters['check_in_to']]);
|
||||
}
|
||||
|
||||
if (! empty($filters['check_out_from']) && ! empty($filters['check_out_to'])) {
|
||||
$query->whereBetween('check_out', [$filters['check_out_from'], $filters['check_out_to']]);
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// 🔽 SORTING
|
||||
// -----------------------------
|
||||
$sortBy = $filters['sort_by'] ?? 'check_in';
|
||||
$sortOrder = $filters['sort_order'] ?? 'desc';
|
||||
$allowedSortColumns = ['id', 'check_in', 'check_out', 'status', 'created_at'];
|
||||
|
||||
if (! in_array($sortBy, $allowedSortColumns)) {
|
||||
$sortBy = 'check_in';
|
||||
}
|
||||
|
||||
$query->orderBy($sortBy, $sortOrder);
|
||||
|
||||
// -----------------------------
|
||||
// 📄 PAGINATION
|
||||
// -----------------------------
|
||||
$perPage = $filters['per_page'] ?? 20;
|
||||
$bookings = $query->paginate($perPage);
|
||||
|
||||
return $this->responseSuccess($bookings, 'Bookings fetched successfully.');
|
||||
} catch (Exception $e) {
|
||||
return $this->responseError([], $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function booking(CustomerBookingStoreRequest $request): JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
// Create main booking
|
||||
$booking = Booking::create([
|
||||
'restaurant_id' => getUserRestaurantId(),
|
||||
'hotel_id' => $data['hotel_id'],
|
||||
'customer_id' => getUserId(),
|
||||
'check_in' => $data['check_in'],
|
||||
'check_out' => $data['check_out'],
|
||||
'check_in_time' => $data['check_in_time'] ?? null,
|
||||
'check_out_time' => $data['check_out_time'] ?? null,
|
||||
'total_adults' => $data['total_adults'],
|
||||
'total_children' => $data['total_children'],
|
||||
'subtotal_amount' => $data['subtotal_amount'],
|
||||
'tax_amount' => $data['tax_amount'] ?? 0,
|
||||
'discount_amount' => $data['discount_amount'] ?? 0,
|
||||
'total_amount' => $data['total_amount'],
|
||||
'payment_status' => $data['payment_status'],
|
||||
'payment_method' => $data['payment_method'] ?? null,
|
||||
'channel' => $data['channel'] ?? null,
|
||||
'status' => $data['status'],
|
||||
'remarks' => $data['remarks'] ?? null,
|
||||
]);
|
||||
|
||||
// Create booking items
|
||||
foreach ($data['booking_items'] as $item) {
|
||||
BookingItem::create([
|
||||
'restaurant_id' => getUserRestaurantId(),
|
||||
'booking_id' => $booking->id,
|
||||
'room_id' => $item['room_id'],
|
||||
'adults' => $item['adults'],
|
||||
'children' => $item['children'],
|
||||
'room_price' => $item['room_price'],
|
||||
'nights' => $item['nights'],
|
||||
'tax_amount' => $item['tax_amount'] ?? 0,
|
||||
'total_amount' => $item['total_amount'],
|
||||
'status' => $item['status'],
|
||||
]);
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
return $this->responseSuccess($booking->load('bookingItems'), 'Booking has been created successfully.');
|
||||
} catch (Exception $e) {
|
||||
DB::rollBack();
|
||||
|
||||
return $this->responseError([], $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Customer\Http\Requests\Auth;
|
||||
|
||||
use Illuminate\Auth\Events\Lockout;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class CustomerLoginRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\Rule|array|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'phone' => 'required|exists:customers,phone',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to authenticate the request's credentials.
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
public function authenticate(): void
|
||||
{
|
||||
$this->ensureIsNotRateLimited();
|
||||
|
||||
// Proceed with normal authentication
|
||||
if (! Auth::attempt($this->only('phone'), $this->boolean('remember'))) {
|
||||
RateLimiter::hit($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'phone' => trans('auth.failed'),
|
||||
]);
|
||||
}
|
||||
|
||||
RateLimiter::clear($this->throttleKey());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the login request is not rate limited.
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
public function ensureIsNotRateLimited(): void
|
||||
{
|
||||
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event(new Lockout($this));
|
||||
|
||||
$seconds = RateLimiter::availableIn($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'phone' => trans('auth.throttle', [
|
||||
'seconds' => $seconds,
|
||||
'minutes' => ceil($seconds / 60),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the rate limiting throttle key for the request.
|
||||
*/
|
||||
public function throttleKey(): string
|
||||
{
|
||||
return Str::transliterate(Str::lower($this->string('phone')).'|'.$this->ip());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Customer\Http\Requests\Auth;
|
||||
|
||||
use Illuminate\Auth\Events\Lockout;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ProfileUpdateRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\Rule|array|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'sometimes|string|max:255',
|
||||
// 'email' => 'sometimes|email|max:255',
|
||||
// 'phone' => 'sometimes|string|max:20',
|
||||
'address' => 'sometimes|string|max:500',
|
||||
'avatar' => 'sometimes|image|mimes:jpeg,png,jpg,gif,svg|max:2048',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to authenticate the request's credentials.
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
public function authenticate(): void
|
||||
{
|
||||
$this->ensureIsNotRateLimited();
|
||||
|
||||
// Proceed with normal authentication
|
||||
if (! Auth::attempt($this->only('phone'), $this->boolean('remember'))) {
|
||||
RateLimiter::hit($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'phone' => trans('auth.failed'),
|
||||
]);
|
||||
}
|
||||
|
||||
RateLimiter::clear($this->throttleKey());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the login request is not rate limited.
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
public function ensureIsNotRateLimited(): void
|
||||
{
|
||||
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event(new Lockout($this));
|
||||
|
||||
$seconds = RateLimiter::availableIn($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'phone' => trans('auth.throttle', [
|
||||
'seconds' => $seconds,
|
||||
'minutes' => ceil($seconds / 60),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the rate limiting throttle key for the request.
|
||||
*/
|
||||
public function throttleKey(): string
|
||||
{
|
||||
return Str::transliterate(Str::lower($this->string('phone')).'|'.$this->ip());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user