From 1f81d98c5235e338df8d0c425d1894d311433f4a Mon Sep 17 00:00:00 2001 From: John Mizerek Date: Sat, 14 Mar 2026 14:26:59 -0700 Subject: [PATCH] Initial PHP API migration from CFML Complete port of all 163 API endpoints from Lucee/CFML to PHP 8.3. Shared helpers in api/helpers.php (DB, auth, request/response, security). PDO prepared statements throughout. Same JSON response shapes as CFML. --- api/addresses/add.php | 72 + api/addresses/delete.php | 38 + api/addresses/list.php | 49 + api/addresses/setDefault.php | 39 + api/addresses/states.php | 24 + api/addresses/types.php | 20 + api/app/about.php | 47 + api/assignments/delete.php | 43 + api/assignments/list.php | 68 + api/assignments/save.php | 67 + api/auth/avatar.php | 110 + api/auth/completeProfile.php | 66 + api/auth/login.php | 50 + api/auth/loginOTP.php | 56 + api/auth/logout.php | 13 + api/auth/profile.php | 97 + api/auth/sendLoginOTP.php | 88 + api/auth/sendOTP.php | 65 + api/auth/validateToken.php | 46 + api/auth/verifyEmailOTP.php | 78 + api/auth/verifyLoginOTP.php | 49 + api/auth/verifyOTP.php | 60 + .../allocate_business_namespace.php | 74 + .../allocate_servicepoint_minor.php | 63 + api/beacon-sharding/get_beacon_config.php | 100 + api/beacon-sharding/get_shard_pool.php | 34 + .../register_beacon_hardware.php | 105 + api/beacon-sharding/resolve_business.php | 85 + api/beacon-sharding/resolve_servicepoint.php | 119 + .../verify_beacon_broadcast.php | 68 + api/beacons/delete.php | 32 + api/beacons/get.php | 39 + api/beacons/getBusinessFromBeacon.php | 116 + api/beacons/list.php | 77 + api/beacons/list_all.php | 22 + api/beacons/lookup.php | 75 + api/beacons/reassign_all.php | 19 + api/beacons/save.php | 103 + api/businesses/get.php | 150 ++ api/businesses/getChildren.php | 44 + api/businesses/list.php | 83 + api/businesses/saveBrandColor.php | 49 + api/businesses/saveOrderTypes.php | 35 + api/businesses/setHiring.php | 30 + api/businesses/update.php | 85 + api/businesses/updateHours.php | 50 + api/businesses/updateTabs.php | 72 + api/chat/closeChat.php | 30 + api/chat/getActiveChat.php | 61 + api/chat/getMessages.php | 74 + api/chat/markRead.php | 36 + api/chat/sendMessage.php | 63 + api/config/stripe.php | 100 + api/grants/_grantUtils.php | 103 + api/grants/accept.php | 78 + api/grants/create.php | 124 ++ api/grants/decline.php | 56 + api/grants/get.php | 96 + api/grants/list.php | 91 + api/grants/revoke.php | 62 + api/grants/searchBusiness.php | 45 + api/grants/update.php | 140 ++ api/helpers.php | 484 +++++ api/menu/clearAllData.php | 40 + api/menu/clearBusinessData.php | 61 + api/menu/clearOrders.php | 43 + api/menu/debug.php | 73 + api/menu/getForBuilder.php | 400 ++++ api/menu/items.php | 489 +++++ api/menu/listCategories.php | 51 + api/menu/menus.php | 181 ++ api/menu/saveCategory.php | 80 + api/menu/saveFromBuilder.php | 258 +++ api/menu/updateStations.php | 57 + api/menu/uploadHeader.php | 105 + api/menu/uploadItemPhoto.php | 152 ++ api/orders/_cartPayload.php | 91 + api/orders/_createOrderTasks.php | 137 ++ api/orders/abandonOrder.php | 38 + api/orders/checkStatusUpdate.php | 72 + api/orders/getActiveCart.php | 68 + api/orders/getCart.php | 124 ++ api/orders/getDetail.php | 186 ++ api/orders/getOrCreateCart.php | 139 ++ api/orders/getPendingForUser.php | 78 + api/orders/history.php | 106 + api/orders/listForKDS.php | 132 ++ api/orders/markStationDone.php | 98 + api/orders/setLineItem.php | 198 ++ api/orders/setOrderType.php | 61 + api/orders/submit.php | 203 ++ api/orders/submitCash.php | 119 + api/orders/updateStatus.php | 48 + api/portal/addTeamMember.php | 39 + api/portal/myBusinesses.php | 35 + api/portal/reassign_employees.php | 15 + api/portal/searchUser.php | 64 + api/portal/stats.php | 51 + api/portal/team.php | 61 + api/presence/heartbeat.php | 32 + api/ratings/createAdminRating.php | 45 + api/ratings/listForAdmin.php | 58 + api/ratings/setup.php | 38 + api/ratings/submit.php | 102 + api/servicepoints/delete.php | 29 + api/servicepoints/get.php | 37 + api/servicepoints/list.php | 89 + api/servicepoints/reassign_all.php | 19 + api/servicepoints/save.php | 93 + api/setup/analyzeMenu.php | 289 +++ api/setup/analyzeMenuImages.php | 344 +++ api/setup/analyzeMenuUrl.php | 1922 +++++++++++++++++ api/setup/checkDuplicate.php | 73 + api/setup/downloadImages.php | 136 ++ api/setup/importBusiness.php | 225 ++ api/setup/lookupTaxRate.php | 133 ++ api/setup/reimportBigDeans.php | 291 +++ api/setup/saveWizard.php | 647 ++++++ api/setup/testUpload.php | 86 + api/setup/uploadSavedPage.php | 178 ++ api/stations/delete.php | 24 + api/stations/list.php | 35 + api/stations/save.php | 51 + api/stripe/createPaymentIntent.php | 280 +++ api/stripe/getPaymentConfig.php | 57 + api/stripe/onboard.php | 61 + api/stripe/status.php | 71 + api/stripe/webhook.php | 293 +++ api/tabs/addMember.php | 61 + api/tabs/addOrder.php | 103 + api/tabs/approveOrder.php | 64 + api/tabs/cancel.php | 41 + api/tabs/close.php | 140 ++ api/tabs/get.php | 110 + api/tabs/getActive.php | 68 + api/tabs/getPresence.php | 65 + api/tabs/increaseAuth.php | 52 + api/tabs/open.php | 142 ++ api/tabs/pendingOrders.php | 59 + api/tabs/rejectOrder.php | 30 + api/tabs/removeMember.php | 32 + api/tasks/accept.php | 53 + api/tasks/callServer.php | 94 + api/tasks/complete.php | 365 ++++ api/tasks/completeChat.php | 46 + api/tasks/create.php | 95 + api/tasks/createChat.php | 172 ++ api/tasks/deleteCategory.php | 52 + api/tasks/deleteType.php | 38 + api/tasks/expireStaleChats.php | 51 + api/tasks/getDetails.php | 183 ++ api/tasks/listAllTypes.php | 58 + api/tasks/listCategories.php | 44 + api/tasks/listMine.php | 134 ++ api/tasks/listPending.php | 118 + api/tasks/listTypes.php | 56 + api/tasks/reorderTypes.php | 38 + api/tasks/saveCategory.php | 68 + api/tasks/saveType.php | 106 + api/tasks/seedCategories.php | 86 + api/users/search.php | 56 + api/workers/createAccount.php | 84 + api/workers/earlyUnlock.php | 78 + api/workers/ledger.php | 66 + api/workers/myBusinesses.php | 59 + api/workers/onboardingLink.php | 66 + api/workers/tierStatus.php | 59 + 167 files changed, 17800 insertions(+) create mode 100644 api/addresses/add.php create mode 100644 api/addresses/delete.php create mode 100644 api/addresses/list.php create mode 100644 api/addresses/setDefault.php create mode 100644 api/addresses/states.php create mode 100644 api/addresses/types.php create mode 100644 api/app/about.php create mode 100644 api/assignments/delete.php create mode 100644 api/assignments/list.php create mode 100644 api/assignments/save.php create mode 100644 api/auth/avatar.php create mode 100644 api/auth/completeProfile.php create mode 100644 api/auth/login.php create mode 100644 api/auth/loginOTP.php create mode 100644 api/auth/logout.php create mode 100644 api/auth/profile.php create mode 100644 api/auth/sendLoginOTP.php create mode 100644 api/auth/sendOTP.php create mode 100644 api/auth/validateToken.php create mode 100644 api/auth/verifyEmailOTP.php create mode 100644 api/auth/verifyLoginOTP.php create mode 100644 api/auth/verifyOTP.php create mode 100644 api/beacon-sharding/allocate_business_namespace.php create mode 100644 api/beacon-sharding/allocate_servicepoint_minor.php create mode 100644 api/beacon-sharding/get_beacon_config.php create mode 100644 api/beacon-sharding/get_shard_pool.php create mode 100644 api/beacon-sharding/register_beacon_hardware.php create mode 100644 api/beacon-sharding/resolve_business.php create mode 100644 api/beacon-sharding/resolve_servicepoint.php create mode 100644 api/beacon-sharding/verify_beacon_broadcast.php create mode 100644 api/beacons/delete.php create mode 100644 api/beacons/get.php create mode 100644 api/beacons/getBusinessFromBeacon.php create mode 100644 api/beacons/list.php create mode 100644 api/beacons/list_all.php create mode 100644 api/beacons/lookup.php create mode 100644 api/beacons/reassign_all.php create mode 100644 api/beacons/save.php create mode 100644 api/businesses/get.php create mode 100644 api/businesses/getChildren.php create mode 100644 api/businesses/list.php create mode 100644 api/businesses/saveBrandColor.php create mode 100644 api/businesses/saveOrderTypes.php create mode 100644 api/businesses/setHiring.php create mode 100644 api/businesses/update.php create mode 100644 api/businesses/updateHours.php create mode 100644 api/businesses/updateTabs.php create mode 100644 api/chat/closeChat.php create mode 100644 api/chat/getActiveChat.php create mode 100644 api/chat/getMessages.php create mode 100644 api/chat/markRead.php create mode 100644 api/chat/sendMessage.php create mode 100644 api/config/stripe.php create mode 100644 api/grants/_grantUtils.php create mode 100644 api/grants/accept.php create mode 100644 api/grants/create.php create mode 100644 api/grants/decline.php create mode 100644 api/grants/get.php create mode 100644 api/grants/list.php create mode 100644 api/grants/revoke.php create mode 100644 api/grants/searchBusiness.php create mode 100644 api/grants/update.php create mode 100644 api/helpers.php create mode 100644 api/menu/clearAllData.php create mode 100644 api/menu/clearBusinessData.php create mode 100644 api/menu/clearOrders.php create mode 100644 api/menu/debug.php create mode 100644 api/menu/getForBuilder.php create mode 100644 api/menu/items.php create mode 100644 api/menu/listCategories.php create mode 100644 api/menu/menus.php create mode 100644 api/menu/saveCategory.php create mode 100644 api/menu/saveFromBuilder.php create mode 100644 api/menu/updateStations.php create mode 100644 api/menu/uploadHeader.php create mode 100644 api/menu/uploadItemPhoto.php create mode 100644 api/orders/_cartPayload.php create mode 100644 api/orders/_createOrderTasks.php create mode 100644 api/orders/abandonOrder.php create mode 100644 api/orders/checkStatusUpdate.php create mode 100644 api/orders/getActiveCart.php create mode 100644 api/orders/getCart.php create mode 100644 api/orders/getDetail.php create mode 100644 api/orders/getOrCreateCart.php create mode 100644 api/orders/getPendingForUser.php create mode 100644 api/orders/history.php create mode 100644 api/orders/listForKDS.php create mode 100644 api/orders/markStationDone.php create mode 100644 api/orders/setLineItem.php create mode 100644 api/orders/setOrderType.php create mode 100644 api/orders/submit.php create mode 100644 api/orders/submitCash.php create mode 100644 api/orders/updateStatus.php create mode 100644 api/portal/addTeamMember.php create mode 100644 api/portal/myBusinesses.php create mode 100644 api/portal/reassign_employees.php create mode 100644 api/portal/searchUser.php create mode 100644 api/portal/stats.php create mode 100644 api/portal/team.php create mode 100644 api/presence/heartbeat.php create mode 100644 api/ratings/createAdminRating.php create mode 100644 api/ratings/listForAdmin.php create mode 100644 api/ratings/setup.php create mode 100644 api/ratings/submit.php create mode 100644 api/servicepoints/delete.php create mode 100644 api/servicepoints/get.php create mode 100644 api/servicepoints/list.php create mode 100644 api/servicepoints/reassign_all.php create mode 100644 api/servicepoints/save.php create mode 100644 api/setup/analyzeMenu.php create mode 100644 api/setup/analyzeMenuImages.php create mode 100644 api/setup/analyzeMenuUrl.php create mode 100644 api/setup/checkDuplicate.php create mode 100644 api/setup/downloadImages.php create mode 100644 api/setup/importBusiness.php create mode 100644 api/setup/lookupTaxRate.php create mode 100644 api/setup/reimportBigDeans.php create mode 100644 api/setup/saveWizard.php create mode 100644 api/setup/testUpload.php create mode 100644 api/setup/uploadSavedPage.php create mode 100644 api/stations/delete.php create mode 100644 api/stations/list.php create mode 100644 api/stations/save.php create mode 100644 api/stripe/createPaymentIntent.php create mode 100644 api/stripe/getPaymentConfig.php create mode 100644 api/stripe/onboard.php create mode 100644 api/stripe/status.php create mode 100644 api/stripe/webhook.php create mode 100644 api/tabs/addMember.php create mode 100644 api/tabs/addOrder.php create mode 100644 api/tabs/approveOrder.php create mode 100644 api/tabs/cancel.php create mode 100644 api/tabs/close.php create mode 100644 api/tabs/get.php create mode 100644 api/tabs/getActive.php create mode 100644 api/tabs/getPresence.php create mode 100644 api/tabs/increaseAuth.php create mode 100644 api/tabs/open.php create mode 100644 api/tabs/pendingOrders.php create mode 100644 api/tabs/rejectOrder.php create mode 100644 api/tabs/removeMember.php create mode 100644 api/tasks/accept.php create mode 100644 api/tasks/callServer.php create mode 100644 api/tasks/complete.php create mode 100644 api/tasks/completeChat.php create mode 100644 api/tasks/create.php create mode 100644 api/tasks/createChat.php create mode 100644 api/tasks/deleteCategory.php create mode 100644 api/tasks/deleteType.php create mode 100644 api/tasks/expireStaleChats.php create mode 100644 api/tasks/getDetails.php create mode 100644 api/tasks/listAllTypes.php create mode 100644 api/tasks/listCategories.php create mode 100644 api/tasks/listMine.php create mode 100644 api/tasks/listPending.php create mode 100644 api/tasks/listTypes.php create mode 100644 api/tasks/reorderTypes.php create mode 100644 api/tasks/saveCategory.php create mode 100644 api/tasks/saveType.php create mode 100644 api/tasks/seedCategories.php create mode 100644 api/users/search.php create mode 100644 api/workers/createAccount.php create mode 100644 api/workers/earlyUnlock.php create mode 100644 api/workers/ledger.php create mode 100644 api/workers/myBusinesses.php create mode 100644 api/workers/onboardingLink.php create mode 100644 api/workers/tierStatus.php diff --git a/api/addresses/add.php b/api/addresses/add.php new file mode 100644 index 0000000..06d152a --- /dev/null +++ b/api/addresses/add.php @@ -0,0 +1,72 @@ + false, 'ERROR' => 'unauthorized', 'MESSAGE' => 'Authentication required']); +} + +try { + $data = readJsonBody(); + + $line1 = trim($data['Line1'] ?? ''); + $city = trim($data['City'] ?? ''); + $stateId = (int) ($data['StateID'] ?? 0); + $zipCode = trim($data['ZIPCode'] ?? ''); + $line2 = trim($data['Line2'] ?? ''); + $label = trim($data['Label'] ?? ''); + $setAsDefault = (bool) ($data['SetAsDefault'] ?? false); + $typeId = 2; // delivery + + if ($line1 === '' || $city === '' || $stateId <= 0 || $zipCode === '') { + apiAbort(['OK' => false, 'ERROR' => 'missing_fields', 'MESSAGE' => 'Line1, City, StateID, and ZIPCode are required']); + } + + if ($setAsDefault) { + queryTimed(" + UPDATE Addresses SET IsDefaultDelivery = 0 + WHERE UserID = ? AND (BusinessID = 0 OR BusinessID IS NULL) AND AddressTypeID = ? + ", [$userId, $typeId]); + } + + // Get next AddressID + $qNext = queryOne("SELECT IFNULL(MAX(ID), 0) + 1 AS NextID FROM Addresses", []); + $newAddressId = (int) $qNext['NextID']; + + queryTimed(" + INSERT INTO Addresses (ID, UserID, BusinessID, AddressTypeID, Label, IsDefaultDelivery, + Line1, Line2, City, StateID, ZIPCode, IsDeleted, AddedOn) + VALUES (?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, 0, NOW()) + ", [ + $newAddressId, $userId, $typeId, $label, $setAsDefault ? 1 : 0, + $line1, $line2, $city, $stateId, $zipCode, + ]); + + $qState = queryOne("SELECT Abbreviation, Name FROM tt_States WHERE ID = ?", [$stateId]); + $stateAbbr = $qState['Abbreviation'] ?? ''; + + $displayText = $line1 . ($line2 !== '' ? ', ' . $line2 : '') . ', ' . $city . ', ' . $stateAbbr . ' ' . $zipCode; + + jsonResponse([ + 'OK' => true, + 'ADDRESS' => [ + 'AddressID' => $newAddressId, + 'TypeID' => $typeId, + 'Label' => $label !== '' ? $label : 'Address', + 'IsDefault' => $setAsDefault, + 'Line1' => $line1, + 'Line2' => $line2, + 'City' => $city, + 'StateID' => $stateId, + 'StateAbbr' => $stateAbbr, + 'StateName' => $qState['Name'] ?? '', + 'ZIPCode' => $zipCode, + 'DisplayText' => $displayText, + ], + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/addresses/delete.php b/api/addresses/delete.php new file mode 100644 index 0000000..d9ca0c6 --- /dev/null +++ b/api/addresses/delete.php @@ -0,0 +1,38 @@ + false, 'ERROR' => 'not_logged_in', 'MESSAGE' => 'Authentication required']); +} + +$data = readJsonBody(); +$addressId = (int) ($data['AddressID'] ?? ($_GET['id'] ?? 0)); + +if ($addressId <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'invalid_id', 'MESSAGE' => 'Address ID required']); +} + +try { + $qAddr = queryOne(" + SELECT Line1, Line2, City, StateID, ZIPCode + FROM Addresses WHERE ID = ? AND UserID = ? AND IsDeleted = 0 + ", [$addressId, $userId]); + + if (!$qAddr) { + apiAbort(['OK' => false, 'ERROR' => 'not_found', 'MESSAGE' => 'Address not found']); + } + + // Soft-delete all matching duplicates + queryTimed(" + UPDATE Addresses SET IsDeleted = 1 + WHERE UserID = ? AND Line1 = ? AND Line2 = ? AND City = ? AND StateID = ? AND ZIPCode = ? AND IsDeleted = 0 + ", [$userId, $qAddr['Line1'], $qAddr['Line2'] ?? '', $qAddr['City'], (int) $qAddr['StateID'], $qAddr['ZIPCode']]); + + jsonResponse(['OK' => true, 'MESSAGE' => 'Address deleted']); + +} catch (Exception $e) { + apiAbort(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/addresses/list.php b/api/addresses/list.php new file mode 100644 index 0000000..b76aecc --- /dev/null +++ b/api/addresses/list.php @@ -0,0 +1,49 @@ + false, 'ERROR' => 'not_logged_in', 'MESSAGE' => 'Authentication required']); +} + +try { + $rows = queryTimed(" + SELECT a.ID, a.IsDefaultDelivery, a.Line1, a.Line2, a.City, a.StateID, + s.Abbreviation AS StateAbbreviation, s.Name AS StateName, a.ZIPCode + FROM Addresses a + LEFT JOIN tt_States s ON a.StateID = s.ID + WHERE a.UserID = ? + AND (a.BusinessID = 0 OR a.BusinessID IS NULL) + AND a.AddressTypeID = 2 + AND a.IsDeleted = 0 + ORDER BY a.IsDefaultDelivery DESC, a.ID DESC + ", [$userId]); + + $addresses = []; + foreach ($rows as $r) { + $line2 = $r['Line2'] ?? ''; + $stateAbbr = $r['StateAbbreviation'] ?? ''; + $displayText = $r['Line1'] + . ($line2 !== '' ? ', ' . $line2 : '') + . ', ' . $r['City'] . ', ' . $stateAbbr . ' ' . $r['ZIPCode']; + + $addresses[] = [ + 'AddressID' => (int) $r['ID'], + 'IsDefault' => (int) $r['IsDefaultDelivery'] === 1, + 'Line1' => $r['Line1'], + 'Line2' => $line2, + 'City' => $r['City'], + 'StateID' => (int) $r['StateID'], + 'StateAbbr' => $stateAbbr, + 'ZIPCode' => $r['ZIPCode'], + 'DisplayText' => $displayText, + ]; + } + + jsonResponse(['OK' => true, 'ADDRESSES' => $addresses]); + +} catch (Exception $e) { + apiAbort(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/addresses/setDefault.php b/api/addresses/setDefault.php new file mode 100644 index 0000000..1bece70 --- /dev/null +++ b/api/addresses/setDefault.php @@ -0,0 +1,39 @@ + false, 'ERROR' => 'unauthorized', 'MESSAGE' => 'Authentication required']); +} + +try { + $data = readJsonBody(); + $addressId = (int) ($data['AddressID'] ?? 0); + + if ($addressId <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'missing_field', 'MESSAGE' => 'AddressID is required']); + } + + $qCheck = queryOne("SELECT ID FROM Addresses WHERE ID = ? AND UserID = ? AND IsDeleted = 0", + [$addressId, $userId]); + + if (!$qCheck) { + apiAbort(['OK' => false, 'ERROR' => 'not_found', 'MESSAGE' => 'Address not found']); + } + + // Clear all defaults + queryTimed(" + UPDATE Addresses SET IsDefaultDelivery = 0 + WHERE UserID = ? AND (BusinessID = 0 OR BusinessID IS NULL) AND AddressTypeID = 2 + ", [$userId]); + + // Set this one + queryTimed("UPDATE Addresses SET IsDefaultDelivery = 1 WHERE ID = ?", [$addressId]); + + jsonResponse(['OK' => true, 'MESSAGE' => 'Default address updated']); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/addresses/states.php b/api/addresses/states.php new file mode 100644 index 0000000..1b67437 --- /dev/null +++ b/api/addresses/states.php @@ -0,0 +1,24 @@ + (int) $r['StateID'], + 'Abbr' => $r['StateAbbreviation'], + 'Name' => $r['StateName'], + ]; + } + + jsonResponse(['OK' => true, 'STATES' => $states]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/addresses/types.php b/api/addresses/types.php new file mode 100644 index 0000000..7270130 --- /dev/null +++ b/api/addresses/types.php @@ -0,0 +1,20 @@ + (int) $r['ID'], 'Label' => $r['Label']]; + } + + jsonResponse(['OK' => true, 'TYPES' => $types]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/app/about.php b/api/app/about.php new file mode 100644 index 0000000..576f367 --- /dev/null +++ b/api/app/about.php @@ -0,0 +1,47 @@ + 'qr_code_scanner', + 'TITLE' => 'Scan & Order', + 'DESCRIPTION' => 'Your phone automatically finds your table location, browse the menu and order directly from your phone', + ], + [ + 'ICON' => 'group', + 'TITLE' => 'Group Orders', + 'DESCRIPTION' => 'Separate checks for everyone, no more confusion about the bill', + ], + [ + 'ICON' => 'delivery_dining', + 'TITLE' => 'Delivery & Takeaway', + 'DESCRIPTION' => 'Order for delivery or pick up when dining in isn\'t an option (coming soon!)', + ], + [ + 'ICON' => 'payment', + 'TITLE' => 'Easy Payment', + 'DESCRIPTION' => 'Easily pay on your phone with just a few taps, no more awkward check danses with staff!', + ], +]; + +$contacts = [ + [ + 'ICON' => 'help_outline', + 'LABEL' => 'help.payfrit.com', + 'URL' => 'https://help.payfrit.com', + ], + [ + 'ICON' => 'language', + 'LABEL' => 'www.payfrit.com', + 'URL' => 'https://www.payfrit.com', + ], +]; + +jsonResponse([ + 'OK' => true, + 'DESCRIPTION' => 'Payfrit makes dining out easier. Order from your phone, separate checks for everyone, and pay without waiting.', + 'FEATURES' => $features, + 'CONTACTS' => $contacts, + 'COPYRIGHT' => '© ' . gmdate('Y') . ' Payfrit. All rights reserved.', +]); diff --git a/api/assignments/delete.php b/api/assignments/delete.php new file mode 100644 index 0000000..3e70c9d --- /dev/null +++ b/api/assignments/delete.php @@ -0,0 +1,43 @@ + false, 'ERROR' => 'no_business_selected']); +} + +$data = readJsonBody(); +$servicePointID = (int) ($data['ServicePointID'] ?? 0); + +if ($servicePointID <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'missing_ServicePointID']); +} + +$qFind = queryOne(" + SELECT ID, BeaconID FROM ServicePoints + WHERE ID = ? AND BusinessID = ? AND BeaconID IS NOT NULL + LIMIT 1 +", [$servicePointID, $businessId]); + +if (!$qFind) { + apiAbort([ + 'OK' => false, 'ERROR' => 'not_found', + 'ServicePointID' => $servicePointID, + 'BusinessID' => (string) $businessId, + ]); +} + +$removedBeaconID = (int) $qFind['BeaconID']; + +queryTimed("UPDATE ServicePoints SET BeaconID = NULL, AssignedByUserID = NULL WHERE ID = ? AND BusinessID = ?", + [$servicePointID, $businessId]); + +jsonResponse([ + 'OK' => true, 'ERROR' => '', + 'ACTION' => 'unassigned', + 'ServicePointID' => $servicePointID, + 'BeaconID' => $removedBeaconID, + 'BusinessID' => (string) $businessId, +]); diff --git a/api/assignments/list.php b/api/assignments/list.php new file mode 100644 index 0000000..8c389be --- /dev/null +++ b/api/assignments/list.php @@ -0,0 +1,68 @@ + false, 'ERROR' => 'no_business_selected']); +} + +$qBiz = queryOne("SELECT BeaconShardID, BeaconMajor FROM Businesses WHERE ID = ? LIMIT 1", [$businessId]); + +$usesSharding = $qBiz && ((int) ($qBiz['BeaconShardID'] ?? 0)) > 0; +$assignments = []; + +if ($usesSharding) { + $qShard = queryOne("SELECT UUID FROM BeaconShards WHERE ID = ?", [(int) $qBiz['BeaconShardID']]); + $shardUUID = $qShard ? $qShard['UUID'] : ''; + + $rows = queryTimed(" + SELECT sp.ID AS ServicePointID, sp.BeaconMinor, sp.Name AS ServicePointName + FROM ServicePoints sp + WHERE sp.BusinessID = ? AND sp.BeaconMinor IS NOT NULL AND sp.IsActive = 1 + ORDER BY sp.BeaconMinor, sp.Name + ", [$businessId]); + + foreach ($rows as $r) { + $assignments[] = [ + 'ServicePointID' => (int) $r['ServicePointID'], + 'BeaconID' => 0, + 'BusinessID' => $businessId, + 'BeaconName' => $r['ServicePointName'] . ' (Minor ' . $r['BeaconMinor'] . ')', + 'UUID' => $shardUUID, + 'Major' => (int) $qBiz['BeaconMajor'], + 'Minor' => (int) $r['BeaconMinor'], + 'ServicePointName' => $r['ServicePointName'], + 'IsSharding' => true, + ]; + } +} else { + $rows = queryTimed(" + SELECT sp.ID AS ServicePointID, sp.BeaconID, sp.BusinessID, + b.Name AS BeaconName, b.UUID, sp.Name AS ServicePointName + FROM ServicePoints sp + JOIN Beacons b ON b.ID = sp.BeaconID + WHERE sp.BusinessID = ? AND sp.BeaconID IS NOT NULL + ORDER BY b.Name, sp.Name + ", [$businessId]); + + foreach ($rows as $r) { + $assignments[] = [ + 'ServicePointID' => (int) $r['ServicePointID'], + 'BeaconID' => (int) $r['BeaconID'], + 'BusinessID' => (int) $r['BusinessID'], + 'BeaconName' => $r['BeaconName'], + 'UUID' => $r['UUID'], + 'ServicePointName' => $r['ServicePointName'], + 'IsSharding' => false, + ]; + } +} + +jsonResponse([ + 'OK' => true, 'ERROR' => '', + 'COUNT' => count($assignments), + 'ASSIGNMENTS' => $assignments, + 'USES_SHARDING' => $usesSharding, +]); diff --git a/api/assignments/save.php b/api/assignments/save.php new file mode 100644 index 0000000..2a3fda9 --- /dev/null +++ b/api/assignments/save.php @@ -0,0 +1,67 @@ + false, 'ERROR' => 'no_business_selected']); +} + +$data = readJsonBody(); + +$beaconID = (int) ($data['BeaconID'] ?? 0); +$servicePointID = (int) ($data['ServicePointID'] ?? 0); + +if ($beaconID <= 0) apiAbort(['OK' => false, 'ERROR' => 'missing_BeaconID']); +if ($servicePointID <= 0) apiAbort(['OK' => false, 'ERROR' => 'missing_ServicePointID']); + +// Get business (check for parent) +$qBiz = queryOne("SELECT ID, ParentBusinessID FROM Businesses WHERE ID = ? LIMIT 1", [$businessId]); + +// Validate beacon access +$sql = " + SELECT b.ID FROM Beacons b + WHERE b.ID = ? AND (b.BusinessID = ? +"; +$params = [$beaconID, $businessId]; + +$parentBizId = (int) ($qBiz['ParentBusinessID'] ?? 0); +if ($parentBizId > 0) { + $sql .= " OR b.BusinessID = ?"; + $params[] = $parentBizId; +} + +$sql .= " OR EXISTS (SELECT 1 FROM lt_BeaconsID_BusinessesID lt WHERE lt.BeaconID = b.ID AND lt.BusinessID = ?)) + LIMIT 1"; +$params[] = $businessId; + +$qB = queryOne($sql, $params); +if (!$qB) { + apiAbort(['OK' => false, 'ERROR' => 'beacon_not_allowed']); +} + +// Validate service point +$qS = queryOne("SELECT ID FROM ServicePoints WHERE ID = ? AND BusinessID = ? LIMIT 1", + [$servicePointID, $businessId]); +if (!$qS) { + apiAbort(['OK' => false, 'ERROR' => 'servicepoint_not_found_for_business']); +} + +// Check duplicate +$qDup = queryOne("SELECT ID FROM ServicePoints WHERE ID = ? AND BeaconID = ? LIMIT 1", + [$servicePointID, $beaconID]); +if ($qDup) { + apiAbort(['OK' => false, 'ERROR' => 'assignment_already_exists']); +} + +queryTimed("UPDATE ServicePoints SET BeaconID = ?, AssignedByUserID = 1 WHERE ID = ? AND BusinessID = ?", + [$beaconID, $servicePointID, $businessId]); + +jsonResponse([ + 'OK' => true, + 'ACTION' => 'assigned', + 'ServicePointID' => $servicePointID, + 'BeaconID' => $beaconID, + 'BusinessID' => (string) $businessId, +]); diff --git a/api/auth/avatar.php b/api/auth/avatar.php new file mode 100644 index 0000000..d574526 --- /dev/null +++ b/api/auth/avatar.php @@ -0,0 +1,110 @@ + false, 'ERROR' => 'not_logged_in', 'MESSAGE' => 'Authentication required']); +} + +$uploadsDir = dirname(__DIR__, 2) . '/uploads/users'; +$avatarUrl = baseUrl() . '/uploads/users/'; + +// Find existing avatar (check multiple extensions) +function findAvatarFile(string $dir, int $uid): ?string { + foreach (['jpg', 'jpeg', 'png', 'gif', 'webp'] as $ext) { + $path = "$dir/$uid.$ext"; + if (file_exists($path)) return $path; + } + return null; +} + +$existingAvatar = findAvatarFile($uploadsDir, $userId); + +// GET — return current avatar URL +if ($_SERVER['REQUEST_METHOD'] === 'GET') { + $hasAvatar = $existingAvatar !== null; + $filename = $hasAvatar ? basename($existingAvatar) : ''; + + jsonResponse([ + 'OK' => true, + 'HAS_AVATAR' => $hasAvatar, + 'AVATAR_URL' => $hasAvatar ? $avatarUrl . $filename . '?t=' . time() : '', + ]); +} + +// POST — upload new avatar +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if (!isset($_FILES['avatar']) || $_FILES['avatar']['error'] !== UPLOAD_ERR_OK) { + apiAbort(['OK' => false, 'ERROR' => 'missing_file', 'MESSAGE' => 'No avatar file provided']); + } + + $file = $_FILES['avatar']; + $allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mime = finfo_file($finfo, $file['tmp_name']); + finfo_close($finfo); + + if (!in_array($mime, $allowed, true)) { + apiAbort(['OK' => false, 'ERROR' => 'invalid_type', 'MESSAGE' => 'Only JPEG, PNG, GIF, or WebP images are accepted']); + } + + if (!is_dir($uploadsDir)) { + mkdir($uploadsDir, 0755, true); + } + + $destPath = "$uploadsDir/$userId.jpg"; + + // Load, resize, and save as JPEG + $img = match ($mime) { + 'image/jpeg' => imagecreatefromjpeg($file['tmp_name']), + 'image/png' => imagecreatefrompng($file['tmp_name']), + 'image/gif' => imagecreatefromgif($file['tmp_name']), + 'image/webp' => imagecreatefromwebp($file['tmp_name']), + }; + + if ($img) { + $w = imagesx($img); + $h = imagesy($img); + + if ($w > 500 || $h > 500) { + $ratio = min(500 / $w, 500 / $h); + $newW = (int) ($w * $ratio); + $newH = (int) ($h * $ratio); + $resized = imagecreatetruecolor($newW, $newH); + imagecopyresampled($resized, $img, 0, 0, 0, 0, $newW, $newH, $w, $h); + imagedestroy($img); + $img = $resized; + } + + imagejpeg($img, $destPath, 85); + imagedestroy($img); + } else { + // Fallback: just move the file + move_uploaded_file($file['tmp_name'], $destPath); + } + + // Delete old avatar with different extension + if ($existingAvatar && $existingAvatar !== $destPath && file_exists($existingAvatar)) { + unlink($existingAvatar); + } + + // Update DB + queryTimed("UPDATE Users SET ImageExtension = 'jpg' WHERE ID = ?", [$userId]); + + jsonResponse([ + 'OK' => true, + 'MESSAGE' => 'Avatar uploaded successfully', + 'AVATAR_URL' => $avatarUrl . $userId . '.jpg?t=' . time(), + ]); +} + +apiAbort(['OK' => false, 'ERROR' => 'bad_method', 'MESSAGE' => 'Use GET or POST']); diff --git a/api/auth/completeProfile.php b/api/auth/completeProfile.php new file mode 100644 index 0000000..113c646 --- /dev/null +++ b/api/auth/completeProfile.php @@ -0,0 +1,66 @@ + false, 'ERROR' => 'unauthorized', 'MESSAGE' => 'Authentication required']); +} + +$data = readJsonBody(); +$firstName = trim($data['firstName'] ?? ''); +$lastName = trim($data['lastName'] ?? ''); +$email = strtolower(trim($data['email'] ?? '')); + +if (empty($firstName)) { + apiAbort(['OK' => false, 'ERROR' => 'missing_first_name', 'MESSAGE' => 'First name is required']); +} +if (empty($lastName)) { + apiAbort(['OK' => false, 'ERROR' => 'missing_last_name', 'MESSAGE' => 'Last name is required']); +} +if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) { + apiAbort(['OK' => false, 'ERROR' => 'invalid_email', 'MESSAGE' => 'Please enter a valid email address']); +} + +// Check if email is already used by another verified account +$emailCheck = queryOne( + "SELECT ID FROM Users WHERE EmailAddress = ? AND IsEmailVerified = 1 AND ID != ? LIMIT 1", + [$email, $userId] +); + +if ($emailCheck) { + apiAbort(['OK' => false, 'ERROR' => 'email_exists', 'MESSAGE' => 'This email is already associated with another account']); +} + +// Get user UUID for email confirmation link +$userRow = queryOne("SELECT UUID FROM Users WHERE ID = ?", [$userId]); + +// Update profile and mark as verified/active +queryTimed( + "UPDATE Users + SET FirstName = ?, LastName = ?, EmailAddress = ?, + IsEmailVerified = 0, IsContactVerified = 1, IsActive = 1 + WHERE ID = ?", + [$firstName, $lastName, $email, $userId] +); + +// Send confirmation email (non-blocking) +$emailSent = false; +$confirmLink = baseUrl() . '/confirm_email.cfm?UUID=' . ($userRow['UUID'] ?? ''); + +// TODO: Email sending integration +// For now, profile is saved without sending email + +$message = $emailSent + ? 'Profile updated. Please check your email to confirm your address.' + : 'Profile updated.'; + +jsonResponse(['OK' => true, 'MESSAGE' => $message]); diff --git a/api/auth/login.php b/api/auth/login.php new file mode 100644 index 0000000..f50ceef --- /dev/null +++ b/api/auth/login.php @@ -0,0 +1,50 @@ + false, 'ERROR' => 'missing_fields']); +} + +$row = queryOne( + "SELECT ID, FirstName + FROM Users + WHERE (EmailAddress = ? OR ContactNumber = ?) + AND Password = ? + AND IsEmailVerified = 1 + AND IsContactVerified > 0 + LIMIT 1", + [$username, $username, md5($password)] +); + +if (!$row) { + apiAbort(['OK' => false, 'ERROR' => 'bad_credentials']); +} + +$token = generateSecureToken(); + +queryTimed( + "INSERT INTO UserTokens (UserID, Token) VALUES (?, ?)", + [$row['ID'], $token] +); + +jsonResponse([ + 'OK' => true, + 'ERROR' => '', + 'UserID' => (int) $row['ID'], + 'FirstName' => $row['FirstName'], + 'Token' => $token, +]); diff --git a/api/auth/loginOTP.php b/api/auth/loginOTP.php new file mode 100644 index 0000000..82adacc --- /dev/null +++ b/api/auth/loginOTP.php @@ -0,0 +1,56 @@ + false, 'ERROR' => 'invalid_phone', 'MESSAGE' => 'Please enter a valid 10-digit phone number']); +} + +$user = queryOne( + "SELECT ID, UUID + FROM Users + WHERE ContactNumber = ? AND IsContactVerified = 1 + LIMIT 1", + [$phone] +); + +if (!$user) { + apiAbort(['OK' => false, 'ERROR' => 'no_account', 'MESSAGE' => "We couldn't find an account with this number. Try signing up instead!"]); +} + +$userUUID = $user['UUID'] ?? ''; +if (empty(trim($userUUID))) { + $userUUID = str_replace('-', '', generateUUID()); + queryTimed("UPDATE Users SET UUID = ? WHERE ID = ?", [$userUUID, $user['ID']]); +} + +$otp = random_int(100000, 999999); +queryTimed("UPDATE Users SET MobileVerifyCode = ? WHERE ID = ?", [$otp, $user['ID']]); + +// Send OTP via Twilio (skip on dev) +$smsMessage = 'Code saved (SMS skipped in dev)'; +$dev = isDev(); + +if (!$dev) { + // TODO: Twilio integration + $smsMessage = 'Login code sent'; +} + +$resp = [ + 'OK' => true, + 'UUID' => $userUUID, + 'MESSAGE' => $smsMessage, +]; +if ($dev) { + $resp['DEV_OTP'] = $otp; +} +jsonResponse($resp); diff --git a/api/auth/logout.php b/api/auth/logout.php new file mode 100644 index 0000000..38a2a04 --- /dev/null +++ b/api/auth/logout.php @@ -0,0 +1,13 @@ + true]); diff --git a/api/auth/profile.php b/api/auth/profile.php new file mode 100644 index 0000000..9b69f81 --- /dev/null +++ b/api/auth/profile.php @@ -0,0 +1,97 @@ + false, 'ERROR' => 'not_logged_in', 'MESSAGE' => 'Authentication required']); +} + +// GET — return profile +if ($_SERVER['REQUEST_METHOD'] === 'GET') { + $user = queryOne( + "SELECT ID, FirstName, LastName, EmailAddress, ContactNumber, ImageExtension, Balance + FROM Users WHERE ID = ? LIMIT 1", + [$userId] + ); + + if (!$user) { + apiAbort(['OK' => false, 'ERROR' => 'user_not_found', 'MESSAGE' => 'User not found']); + } + + $avatarUrl = ''; + if (!empty(trim($user['ImageExtension'] ?? ''))) { + $avatarUrl = baseUrl() . '/uploads/users/' . $user['ID'] . '.' . $user['ImageExtension'] . '?t=' . time(); + } + + jsonResponse([ + 'OK' => true, + 'USER' => [ + 'UserID' => (int) $user['ID'], + 'FirstName' => $user['FirstName'] ?? '', + 'LastName' => $user['LastName'] ?? '', + 'Email' => $user['EmailAddress'] ?? '', + 'Phone' => $user['ContactNumber'] ?? '', + 'AvatarUrl' => $avatarUrl, + 'Balance' => (float) ($user['Balance'] ?? 0), + ], + ]); +} + +// POST — update profile +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $data = readJsonBody(); + + $sets = []; + $params = []; + + if (isset($data['firstName'])) { + $sets[] = 'FirstName = ?'; + $params[] = $data['firstName']; + } + if (isset($data['lastName'])) { + $sets[] = 'LastName = ?'; + $params[] = $data['lastName']; + } + + if (empty($sets)) { + apiAbort(['OK' => false, 'ERROR' => 'no_changes', 'MESSAGE' => 'No fields to update']); + } + + $params[] = $userId; + queryTimed("UPDATE Users SET " . implode(', ', $sets) . " WHERE ID = ?", $params); + + // Return updated profile + $user = queryOne( + "SELECT ID, FirstName, LastName, EmailAddress, ContactNumber, ImageExtension + FROM Users WHERE ID = ? LIMIT 1", + [$userId] + ); + + $avatarUrl = ''; + if (!empty(trim($user['ImageExtension'] ?? ''))) { + $avatarUrl = baseUrl() . '/uploads/users/' . $user['ID'] . '.' . $user['ImageExtension'] . '?t=' . time(); + } + + jsonResponse([ + 'OK' => true, + 'MESSAGE' => 'Profile updated', + 'USER' => [ + 'UserID' => (int) $user['ID'], + 'FirstName' => $user['FirstName'] ?? '', + 'LastName' => $user['LastName'] ?? '', + 'Email' => $user['EmailAddress'] ?? '', + 'Phone' => $user['ContactNumber'] ?? '', + 'AvatarUrl' => $avatarUrl, + ], + ]); +} + +apiAbort(['OK' => false, 'ERROR' => 'bad_method', 'MESSAGE' => 'Use GET or POST']); diff --git a/api/auth/sendLoginOTP.php b/api/auth/sendLoginOTP.php new file mode 100644 index 0000000..53f4033 --- /dev/null +++ b/api/auth/sendLoginOTP.php @@ -0,0 +1,88 @@ + false, 'ERROR' => 'missing_identifier', 'MESSAGE' => 'Email or phone is required']); +} + +$isPhone = isPhoneNumber($identifier); +$email = ''; +$phone = ''; + +if ($isPhone) { + $phone = normalizePhone($identifier); +} else { + $email = $identifier; +} + +$genericResponse = ['OK' => true, 'MESSAGE' => 'If an account exists, a code has been sent.']; + +try { + if (!empty($email)) { + $user = queryOne( + "SELECT ID, FirstName, ContactNumber FROM Users WHERE EmailAddress = ? AND IsActive = 1 LIMIT 1", + [$email] + ); + } else { + $user = queryOne( + "SELECT ID, FirstName, ContactNumber FROM Users WHERE ContactNumber = ? AND IsActive = 1 LIMIT 1", + [$phone] + ); + } + + // Always return OK to not reveal if account exists + if (!$user) { + jsonResponse($genericResponse); + } + + $uid = (int) $user['ID']; + + // Rate limit: max 3 codes per user in last 10 minutes + $rateCheck = queryOne( + "SELECT COUNT(*) AS cnt FROM OTPCodes WHERE UserID = ? AND CreatedAt > DATE_SUB(NOW(), INTERVAL 10 MINUTE)", + [$uid] + ); + + if (((int) ($rateCheck['cnt'] ?? 0)) >= 3) { + jsonResponse($genericResponse); + } + + $code = random_int(100000, 999999); + $dev = isDev(); + + // Store with 10-minute expiry + queryTimed( + "INSERT INTO OTPCodes (UserID, Code, ExpiresAt) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 10 MINUTE))", + [$uid, $code] + ); + + // Send OTP via email or SMS (skip on dev) + if (!$dev) { + if (!empty($phone) && !empty($user['ContactNumber'])) { + // TODO: Twilio SMS + } else { + // TODO: Email sending + } + } + + $resp = $genericResponse; + if ($dev) { + $resp['DEV_OTP'] = $code; + } + jsonResponse($resp); + +} catch (\Exception $e) { + // Swallow errors — always return generic OK + error_log("sendLoginOTP error for {$email}: " . $e->getMessage()); + jsonResponse($genericResponse); +} diff --git a/api/auth/sendOTP.php b/api/auth/sendOTP.php new file mode 100644 index 0000000..b2b9b13 --- /dev/null +++ b/api/auth/sendOTP.php @@ -0,0 +1,65 @@ + false, 'ERROR' => 'invalid_phone', 'MESSAGE' => 'Please enter a valid 10-digit phone number']); +} + +$otp = random_int(100000, 999999); + +$existing = queryOne( + "SELECT ID, UUID, FirstName, IsContactVerified, IsActive + FROM Users + WHERE ContactNumber = ? + LIMIT 1", + [$phone] +); + +$userUUID = ''; + +if ($existing) { + $userUUID = $existing['UUID'] ?? ''; + if (empty(trim($userUUID))) { + $userUUID = str_replace('-', '', generateUUID()); + } + queryTimed( + "UPDATE Users SET MobileVerifyCode = ?, UUID = ? WHERE ID = ?", + [$otp, $userUUID, $existing['ID']] + ); +} else { + $userUUID = str_replace('-', '', generateUUID()); + queryTimed( + "INSERT INTO Users (ContactNumber, UUID, MobileVerifyCode, IsContactVerified, IsEmailVerified, IsActive, AddedOn, Password, PromoCode) + VALUES (?, ?, ?, 0, 0, 0, ?, '', ?)", + [$phone, $userUUID, $otp, gmdate('Y-m-d H:i:s'), (string) random_int(1000000, 9999999)] + ); +} + +// Send OTP via Twilio (skip on dev) +$smsMessage = 'Code saved (SMS skipped in dev)'; +$dev = isDev(); + +if (!$dev) { + // TODO: Twilio integration + $smsMessage = 'Code sent'; +} + +$resp = [ + 'OK' => true, + 'UUID' => $userUUID, + 'MESSAGE' => $smsMessage, +]; +if ($dev) { + $resp['DEV_OTP'] = $otp; +} +jsonResponse($resp); diff --git a/api/auth/validateToken.php b/api/auth/validateToken.php new file mode 100644 index 0000000..90b95a3 --- /dev/null +++ b/api/auth/validateToken.php @@ -0,0 +1,46 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'Token is required']); +} + +$row = queryOne( + "SELECT ut.UserID, u.FirstName, u.LastName + FROM UserTokens ut + JOIN Users u ON u.ID = ut.UserID + WHERE ut.Token = ? + LIMIT 1", + [$token] +); + +if (!$row) { + apiAbort(['OK' => false, 'ERROR' => 'invalid_token', 'MESSAGE' => 'Token is invalid or expired']); +} + +$uid = (int) $row['UserID']; + +// Check if user is a worker (has any active employment) +$worker = queryOne( + "SELECT COUNT(*) AS cnt FROM Employees WHERE UserID = ? AND IsActive = 1", + [$uid] +); + +$userType = ((int) ($worker['cnt'] ?? 0)) > 0 ? 'worker' : 'customer'; + +jsonResponse([ + 'OK' => true, + 'UserID' => $uid, + 'UserType' => $userType, + 'UserName' => trim($row['FirstName'] . ' ' . $row['LastName']), +]); diff --git a/api/auth/verifyEmailOTP.php b/api/auth/verifyEmailOTP.php new file mode 100644 index 0000000..5358cb0 --- /dev/null +++ b/api/auth/verifyEmailOTP.php @@ -0,0 +1,78 @@ + false, 'ERROR' => 'missing_fields', 'MESSAGE' => 'Email/phone and code are required']); +} + +$isPhone = isPhoneNumber($identifier); +$email = ''; +$phone = ''; + +if ($isPhone) { + $phone = normalizePhone($identifier); +} else { + $email = $identifier; +} + +if (!empty($email)) { + $user = queryOne( + "SELECT ID, FirstName FROM Users WHERE EmailAddress = ? AND IsActive = 1 LIMIT 1", + [$email] + ); +} else { + $user = queryOne( + "SELECT ID, FirstName FROM Users WHERE ContactNumber = ? AND IsActive = 1 LIMIT 1", + [$phone] + ); +} + +if (!$user) { + apiAbort(['OK' => false, 'ERROR' => 'invalid_code', 'MESSAGE' => 'Invalid or expired code']); +} + +$uid = (int) $user['ID']; + +// Check for valid OTP in OTPCodes table +$otpRow = queryOne( + "SELECT ID FROM OTPCodes + WHERE UserID = ? AND Code = ? AND ExpiresAt > NOW() AND UsedAt IS NULL + ORDER BY CreatedAt DESC + LIMIT 1", + [$uid, $code] +); + +if (!$otpRow) { + apiAbort(['OK' => false, 'ERROR' => 'invalid_code', 'MESSAGE' => 'Invalid or expired code']); +} + +// Mark OTP as used +queryTimed("UPDATE OTPCodes SET UsedAt = NOW() WHERE ID = ?", [$otpRow['ID']]); + +// Create auth token +$token = generateSecureToken(); +queryTimed( + "INSERT INTO UserTokens (UserID, Token) VALUES (?, ?)", + [$uid, $token] +); + +jsonResponse([ + 'OK' => true, + 'ERROR' => '', + 'UserID' => $uid, + 'FirstName' => $user['FirstName'], + 'Token' => $token, +]); diff --git a/api/auth/verifyLoginOTP.php b/api/auth/verifyLoginOTP.php new file mode 100644 index 0000000..8c4e260 --- /dev/null +++ b/api/auth/verifyLoginOTP.php @@ -0,0 +1,49 @@ + false, 'ERROR' => 'missing_fields', 'MESSAGE' => 'UUID and OTP are required']); +} + +$user = queryOne( + "SELECT ID, FirstName, LastName, MobileVerifyCode + FROM Users + WHERE UUID = ? AND IsContactVerified = 1 + LIMIT 1", + [$userUUID] +); + +if (!$user) { + apiAbort(['OK' => false, 'ERROR' => 'expired', 'MESSAGE' => 'Session expired. Please request a new code.']); +} + +if ((string) $user['MobileVerifyCode'] !== (string) $otp) { + apiAbort(['OK' => false, 'ERROR' => 'invalid_otp', 'MESSAGE' => 'Invalid code. Please try again.']); +} + +// Clear OTP (one-time use) +queryTimed("UPDATE Users SET MobileVerifyCode = '' WHERE ID = ?", [$user['ID']]); + +$token = generateSecureToken(); +queryTimed( + "INSERT INTO UserTokens (UserID, Token) VALUES (?, ?)", + [$user['ID'], $token] +); + +jsonResponse([ + 'OK' => true, + 'UserID' => (int) $user['ID'], + 'Token' => $token, + 'FirstName' => $user['FirstName'] ?? '', +]); diff --git a/api/auth/verifyOTP.php b/api/auth/verifyOTP.php new file mode 100644 index 0000000..5314570 --- /dev/null +++ b/api/auth/verifyOTP.php @@ -0,0 +1,60 @@ + false, 'ERROR' => 'missing_fields', 'MESSAGE' => 'UUID and OTP are required']); +} + +$user = queryOne( + "SELECT ID, FirstName, LastName, EmailAddress, IsContactVerified, IsEmailVerified, IsActive, MobileVerifyCode + FROM Users + WHERE UUID = ? + LIMIT 1", + [$userUUID] +); + +if (!$user) { + apiAbort(['OK' => false, 'ERROR' => 'expired', 'MESSAGE' => 'Verification expired. Please request a new code.']); +} + +// Check OTP (no magic OTP in PHP port — use DEV_OTP from send endpoint for dev testing) +if ((string) $user['MobileVerifyCode'] !== (string) $otp) { + apiAbort(['OK' => false, 'ERROR' => 'invalid_otp', 'MESSAGE' => 'Invalid verification code. Please try again.']); +} + +$needsProfile = empty(trim($user['FirstName'] ?? '')); + +if ($needsProfile) { + queryTimed("UPDATE Users SET MobileVerifyCode = '' WHERE ID = ?", [$user['ID']]); +} else { + queryTimed( + "UPDATE Users SET MobileVerifyCode = '', IsContactVerified = 1, IsActive = 1 WHERE ID = ?", + [$user['ID']] + ); +} + +$token = generateSecureToken(); +queryTimed( + "INSERT INTO UserTokens (UserID, Token) VALUES (?, ?)", + [$user['ID'], $token] +); + +jsonResponse([ + 'OK' => true, + 'UserID' => (int) $user['ID'], + 'Token' => $token, + 'NeedsProfile' => $needsProfile, + 'FirstName' => $user['FirstName'] ?? '', + 'IsEmailVerified' => ((int) ($user['IsEmailVerified'] ?? 0)) === 1, +]); diff --git a/api/beacon-sharding/allocate_business_namespace.php b/api/beacon-sharding/allocate_business_namespace.php new file mode 100644 index 0000000..04c95d7 --- /dev/null +++ b/api/beacon-sharding/allocate_business_namespace.php @@ -0,0 +1,74 @@ + false, 'ERROR' => 'missing_business_id', 'MESSAGE' => 'BusinessID is required']); +} + +try { + $qBiz = queryOne("SELECT ID, BeaconShardID, BeaconMajor FROM Businesses WHERE ID = ? LIMIT 1", [$bizId]); + if (!$qBiz) { + apiAbort(['OK' => false, 'ERROR' => 'invalid_business', 'MESSAGE' => 'Business not found']); + } + + // Already allocated + if ((int) ($qBiz['BeaconShardID'] ?? 0) > 0 && $qBiz['BeaconMajor'] !== null) { + $qShard = queryOne("SELECT UUID FROM BeaconShards WHERE ID = ?", [(int) $qBiz['BeaconShardID']]); + jsonResponse([ + 'OK' => true, + 'BusinessID' => $bizId, + 'BeaconShardUUID' => $qShard['UUID'], + 'BeaconMajor' => (int) $qBiz['BeaconMajor'], + 'ShardID' => (int) $qBiz['BeaconShardID'], + 'AlreadyAllocated' => true, + ]); + } + + // Find shard with lowest utilization + $qShard = queryOne(" + SELECT ID, UUID, BusinessCount FROM BeaconShards + WHERE IsActive = 1 AND BusinessCount < MaxBusinesses + ORDER BY BusinessCount ASC LIMIT 1 + ", []); + + if (!$qShard) { + apiAbort(['OK' => false, 'ERROR' => 'no_available_shards', 'MESSAGE' => 'All beacon shards are at capacity']); + } + + $shardId = (int) $qShard['ID']; + $shardUUID = $qShard['UUID']; + + $qMaxMajor = queryOne(" + SELECT COALESCE(MAX(BeaconMajor), -1) AS MaxMajor FROM Businesses WHERE BeaconShardID = ? + ", [$shardId]); + $nextMajor = (int) $qMaxMajor['MaxMajor'] + 1; + + if ($nextMajor > 65535) { + apiAbort(['OK' => false, 'ERROR' => 'shard_full', 'MESSAGE' => 'Shard has reached maximum major value']); + } + + queryTimed(" + UPDATE Businesses SET BeaconShardID = ?, BeaconMajor = ? + WHERE ID = ? AND (BeaconShardID IS NULL OR BeaconMajor IS NULL) + ", [$shardId, $nextMajor, $bizId]); + + queryTimed("UPDATE BeaconShards SET BusinessCount = BusinessCount + 1 WHERE ID = ?", [$shardId]); + + jsonResponse([ + 'OK' => true, + 'BusinessID' => $bizId, + 'BeaconShardUUID' => $shardUUID, + 'BeaconMajor' => $nextMajor, + 'ShardID' => $shardId, + 'AlreadyAllocated' => false, + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/beacon-sharding/allocate_servicepoint_minor.php b/api/beacon-sharding/allocate_servicepoint_minor.php new file mode 100644 index 0000000..b37a150 --- /dev/null +++ b/api/beacon-sharding/allocate_servicepoint_minor.php @@ -0,0 +1,63 @@ + false, 'ERROR' => 'missing_business_id', 'MESSAGE' => 'BusinessID is required']); +} + +$spId = (int) ($data['ServicePointID'] ?? ($_GET['ServicePointID'] ?? 0)); +if ($spId <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'missing_servicepoint_id', 'MESSAGE' => 'ServicePointID is required']); +} + +try { + $qSP = queryOne("SELECT ID, BusinessID, Name, BeaconMinor FROM ServicePoints WHERE ID = ? LIMIT 1", [$spId]); + if (!$qSP) { + apiAbort(['OK' => false, 'ERROR' => 'invalid_servicepoint', 'MESSAGE' => 'Service point not found']); + } + if ((int) $qSP['BusinessID'] !== $bizId) { + apiAbort(['OK' => false, 'ERROR' => 'access_denied', 'MESSAGE' => 'Service point does not belong to this business']); + } + + // Already allocated + if ($qSP['BeaconMinor'] !== null) { + jsonResponse([ + 'OK' => true, + 'ServicePointID' => $spId, + 'BusinessID' => $bizId, + 'BeaconMinor' => (int) $qSP['BeaconMinor'], + 'ServicePointName' => $qSP['Name'], + 'AlreadyAllocated' => true, + ]); + } + + $qMaxMinor = queryOne(" + SELECT COALESCE(MAX(BeaconMinor), -1) AS MaxMinor FROM ServicePoints WHERE BusinessID = ? + ", [$bizId]); + $nextMinor = (int) $qMaxMinor['MaxMinor'] + 1; + + if ($nextMinor > 65535) { + apiAbort(['OK' => false, 'ERROR' => 'business_full', 'MESSAGE' => 'Business has reached maximum service points (65535)']); + } + + queryTimed("UPDATE ServicePoints SET BeaconMinor = ? WHERE ID = ? AND BeaconMinor IS NULL", + [$nextMinor, $spId]); + + jsonResponse([ + 'OK' => true, + 'ServicePointID' => $spId, + 'BusinessID' => $bizId, + 'BeaconMinor' => $nextMinor, + 'ServicePointName' => $qSP['Name'], + 'AlreadyAllocated' => false, + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/beacon-sharding/get_beacon_config.php b/api/beacon-sharding/get_beacon_config.php new file mode 100644 index 0000000..b011e53 --- /dev/null +++ b/api/beacon-sharding/get_beacon_config.php @@ -0,0 +1,100 @@ + false, 'ERROR' => 'missing_business_id', 'MESSAGE' => 'BusinessID is required']); +} + +$spId = (int) ($data['ServicePointID'] ?? 0); +if ($spId <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'missing_servicepoint_id', 'MESSAGE' => 'ServicePointID is required']); +} + +try { + $qBiz = queryOne("SELECT ID, Name, BeaconShardID, BeaconMajor FROM Businesses WHERE ID = ? LIMIT 1", [$bizId]); + if (!$qBiz) { + apiAbort(['OK' => false, 'ERROR' => 'invalid_business', 'MESSAGE' => 'Business not found']); + } + + $shardId = (int) ($qBiz['BeaconShardID'] ?? 0); + $beaconMajor = (int) ($qBiz['BeaconMajor'] ?? 0); + + // Allocate shard if needed + if ($shardId <= 0 || $qBiz['BeaconMajor'] === null) { + $qShard = queryOne(" + SELECT ID, UUID, BusinessCount FROM BeaconShards + WHERE IsActive = 1 AND BusinessCount < MaxBusinesses + ORDER BY BusinessCount ASC LIMIT 1 + ", []); + + if (!$qShard) { + apiAbort(['OK' => false, 'ERROR' => 'no_available_shards', 'MESSAGE' => 'All beacon shards are at capacity']); + } + + $shardId = (int) $qShard['ID']; + + $qMaxMajor = queryOne(" + SELECT COALESCE(MAX(BeaconMajor), -1) AS MaxMajor FROM Businesses WHERE BeaconShardID = ? + ", [$shardId]); + $beaconMajor = (int) $qMaxMajor['MaxMajor'] + 1; + + if ($beaconMajor > 65535) { + apiAbort(['OK' => false, 'ERROR' => 'shard_full', 'MESSAGE' => 'Shard has reached maximum major value']); + } + + queryTimed(" + UPDATE Businesses SET BeaconShardID = ?, BeaconMajor = ? + WHERE ID = ? AND (BeaconShardID IS NULL OR BeaconMajor IS NULL) + ", [$shardId, $beaconMajor, $bizId]); + + queryTimed("UPDATE BeaconShards SET BusinessCount = BusinessCount + 1 WHERE ID = ?", [$shardId]); + } + + $qShardUUID = queryOne("SELECT UUID FROM BeaconShards WHERE ID = ?", [$shardId]); + + // Get service point and allocate Minor if needed + $qSP = queryOne("SELECT ID, BusinessID, Name, BeaconMinor FROM ServicePoints WHERE ID = ? LIMIT 1", [$spId]); + if (!$qSP) { + apiAbort(['OK' => false, 'ERROR' => 'invalid_servicepoint', 'MESSAGE' => 'Service point not found']); + } + if ((int) $qSP['BusinessID'] !== $bizId) { + apiAbort(['OK' => false, 'ERROR' => 'access_denied', 'MESSAGE' => 'Service point does not belong to this business']); + } + + if ($qSP['BeaconMinor'] !== null) { + $beaconMinor = (int) $qSP['BeaconMinor']; + } else { + $qMaxMinor = queryOne(" + SELECT COALESCE(MAX(BeaconMinor), -1) AS MaxMinor FROM ServicePoints WHERE BusinessID = ? + ", [$bizId]); + $beaconMinor = (int) $qMaxMinor['MaxMinor'] + 1; + + if ($beaconMinor > 65535) { + apiAbort(['OK' => false, 'ERROR' => 'business_full', 'MESSAGE' => 'Business has reached maximum service points (65535)']); + } + + queryTimed("UPDATE ServicePoints SET BeaconMinor = ? WHERE ID = ? AND BeaconMinor IS NULL", + [$beaconMinor, $spId]); + } + + jsonResponse([ + 'OK' => true, + 'UUID' => $qShardUUID['UUID'], + 'Major' => $beaconMajor, + 'Minor' => $beaconMinor, + 'MeasuredPower' => -100, + 'AdvInterval' => 2, + 'TxPower' => 1, + 'ServicePointName' => $qSP['Name'], + 'BusinessName' => $qBiz['Name'], + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/beacon-sharding/get_shard_pool.php b/api/beacon-sharding/get_shard_pool.php new file mode 100644 index 0000000..b57d910 --- /dev/null +++ b/api/beacon-sharding/get_shard_pool.php @@ -0,0 +1,34 @@ + 0) { + $sql .= " AND ID > ?"; + $params[] = $sinceId; + } + $sql .= " ORDER BY ID ASC"; + + $rows = queryTimed($sql, $params); + + $shards = []; + $maxId = 0; + foreach ($rows as $r) { + $shards[] = ['ID' => (int) $r['ID'], 'UUID' => $r['UUID']]; + if ((int) $r['ID'] > $maxId) $maxId = (int) $r['ID']; + } + + jsonResponse([ + 'OK' => true, + 'Version' => $maxId, + 'Count' => count($shards), + 'Shards' => $shards, + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/beacon-sharding/register_beacon_hardware.php b/api/beacon-sharding/register_beacon_hardware.php new file mode 100644 index 0000000..20e5c94 --- /dev/null +++ b/api/beacon-sharding/register_beacon_hardware.php @@ -0,0 +1,105 @@ + false, 'ERROR' => 'missing_hardware_id', 'MESSAGE' => 'HardwareId is required']); +} + +$bizId = (int) ($data['BusinessID'] ?? 0); +if ($bizId <= 0) apiAbort(['OK' => false, 'ERROR' => 'missing_business_id', 'MESSAGE' => 'BusinessID is required']); + +$spId = (int) ($data['ServicePointID'] ?? 0); +if ($spId <= 0) apiAbort(['OK' => false, 'ERROR' => 'missing_servicepoint_id', 'MESSAGE' => 'ServicePointID is required']); + +$beaconUUID = trim($data['UUID'] ?? ''); +if ($beaconUUID === '') apiAbort(['OK' => false, 'ERROR' => 'missing_uuid', 'MESSAGE' => 'UUID is required']); + +$major = (int) ($data['Major'] ?? 0); +$minor = (int) ($data['Minor'] ?? 0); + +$txPower = isset($data['TxPower']) && is_numeric($data['TxPower']) ? (int) $data['TxPower'] : null; +$advInterval = isset($data['AdvertisingInterval']) && is_numeric($data['AdvertisingInterval']) ? (int) $data['AdvertisingInterval'] : null; +$firmwareVersion = trim($data['FirmwareVersion'] ?? ''); + +try { + // Verify business namespace + $qBiz = queryOne(" + SELECT b.ID, b.BeaconShardID, b.BeaconMajor, bs.UUID AS ShardUUID + FROM Businesses b LEFT JOIN BeaconShards bs ON b.BeaconShardID = bs.ID + WHERE b.ID = ? LIMIT 1 + ", [$bizId]); + + if (!$qBiz) apiAbort(['OK' => false, 'ERROR' => 'invalid_business', 'MESSAGE' => 'Business not found']); + + if (strcasecmp($qBiz['ShardUUID'] ?? '', $beaconUUID) !== 0) { + apiAbort(['OK' => false, 'ERROR' => 'uuid_mismatch', 'MESSAGE' => 'UUID does not match business\'s assigned shard', + 'ExpectedUUID' => $qBiz['ShardUUID'], 'ProvidedUUID' => $beaconUUID]); + } + if ((int) $qBiz['BeaconMajor'] !== $major) { + apiAbort(['OK' => false, 'ERROR' => 'major_mismatch', 'MESSAGE' => 'Major does not match business\'s assigned value', + 'ExpectedMajor' => (int) $qBiz['BeaconMajor'], 'ProvidedMajor' => $major]); + } + + // Verify service point + $qSP = queryOne("SELECT ID, BusinessID, BeaconMinor, Name FROM ServicePoints WHERE ID = ? LIMIT 1", [$spId]); + if (!$qSP) apiAbort(['OK' => false, 'ERROR' => 'invalid_servicepoint', 'MESSAGE' => 'Service point not found']); + if ((int) $qSP['BusinessID'] !== $bizId) { + apiAbort(['OK' => false, 'ERROR' => 'servicepoint_business_mismatch', 'MESSAGE' => 'Service point does not belong to this business']); + } + + if ($qSP['BeaconMinor'] === null) { + queryTimed("UPDATE ServicePoints SET BeaconMinor = ? WHERE ID = ?", [$minor, $spId]); + } elseif ((int) $qSP['BeaconMinor'] !== $minor) { + apiAbort(['OK' => false, 'ERROR' => 'minor_mismatch', 'MESSAGE' => 'Minor does not match service point\'s assigned value', + 'ExpectedMinor' => (int) $qSP['BeaconMinor'], 'ProvidedMinor' => $minor]); + } + + // Upsert BeaconHardware + $qExisting = queryOne("SELECT ID, Status FROM BeaconHardware WHERE HardwareId = ? LIMIT 1", [$hardwareId]); + + if ($qExisting) { + $setClauses = ["BusinessID = ?", "ServicePointID = ?", "ShardUUID = ?", "Major = ?", "Minor = ?", "Status = 'assigned'"]; + $params = [$bizId, $spId, $beaconUUID, $major, $minor]; + + if ($txPower !== null) { $setClauses[] = "TxPower = ?"; $params[] = $txPower; } + if ($advInterval !== null) { $setClauses[] = "AdvertisingInterval = ?"; $params[] = $advInterval; } + if ($firmwareVersion !== '') { $setClauses[] = "FirmwareVersion = ?"; $params[] = $firmwareVersion; } + + $setClauses[] = "UpdatedAt = NOW()"; + $params[] = $hardwareId; + + queryTimed("UPDATE BeaconHardware SET " . implode(', ', $setClauses) . " WHERE HardwareId = ?", $params); + $hwId = (int) $qExisting['ID']; + } else { + $cols = ['HardwareId', 'BusinessID', 'ServicePointID', 'ShardUUID', 'Major', 'Minor', 'Status']; + $vals = ['?', '?', '?', '?', '?', '?', "'assigned'"]; + $params = [$hardwareId, $bizId, $spId, $beaconUUID, $major, $minor]; + + if ($txPower !== null) { $cols[] = 'TxPower'; $vals[] = '?'; $params[] = $txPower; } + if ($advInterval !== null) { $cols[] = 'AdvertisingInterval'; $vals[] = '?'; $params[] = $advInterval; } + if ($firmwareVersion !== '') { $cols[] = 'FirmwareVersion'; $vals[] = '?'; $params[] = $firmwareVersion; } + + queryTimed("INSERT INTO BeaconHardware (" . implode(', ', $cols) . ") VALUES (" . implode(', ', $vals) . ")", $params); + $hwId = (int) lastInsertId(); + } + + jsonResponse([ + 'OK' => true, + 'BeaconHardwareID' => $hwId, + 'HardwareId' => $hardwareId, + 'BusinessID' => $bizId, + 'ServicePointID' => $spId, + 'ServicePointName' => $qSP['Name'], + 'UUID' => $beaconUUID, + 'Major' => $major, + 'Minor' => $minor, + 'Status' => 'assigned', + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/beacon-sharding/resolve_business.php b/api/beacon-sharding/resolve_business.php new file mode 100644 index 0000000..db73a12 --- /dev/null +++ b/api/beacon-sharding/resolve_business.php @@ -0,0 +1,85 @@ + false, 'Error' => 'not_found']; + } + + $headerImageURL = ''; + if (!empty($qBiz['HeaderImageExtension'])) { + $headerImageURL = '/uploads/headers/' . $qBiz['ID'] . '.' . $qBiz['HeaderImageExtension']; + } + + return [ + 'Found' => true, + 'BusinessID' => (int) $qBiz['ID'], + 'BusinessName' => $qBiz['Name'], + 'BrandColor' => $qBiz['BrandColor'] ?? '', + 'HeaderImageURL' => $headerImageURL, + ]; +} + +try { + $data = readJsonBody(); + + // Batch + if (isset($data['Beacons']) && is_array($data['Beacons'])) { + $results = []; + foreach ($data['Beacons'] as $beacon) { + $uuid = trim($beacon['UUID'] ?? ''); + $major = isset($beacon['Major']) && is_numeric($beacon['Major']) ? (int) $beacon['Major'] : 0; + + if ($uuid === '' || $major <= 0) { + $results[] = ['UUID' => $uuid, 'Major' => $major, 'BusinessID' => null, 'Error' => 'invalid_params']; + continue; + } + + $resolved = resolveSingleBiz($uuid, $major); + if ($resolved['Found']) { + $results[] = [ + 'UUID' => $uuid, 'Major' => $major, + 'BusinessID' => $resolved['BusinessID'], + 'BusinessName' => $resolved['BusinessName'], + 'BrandColor' => $resolved['BrandColor'], + 'HeaderImageURL' => $resolved['HeaderImageURL'], + ]; + } else { + $results[] = ['UUID' => $uuid, 'Major' => $major, 'BusinessID' => null, 'Error' => 'not_found']; + } + } + jsonResponse(['OK' => true, 'COUNT' => count($results), 'Results' => $results]); + } + + // Single + $uuid = trim($data['UUID'] ?? ($_GET['UUID'] ?? '')); + $major = (int) ($data['Major'] ?? ($_GET['Major'] ?? 0)); + + if ($uuid === '') apiAbort(['OK' => false, 'ERROR' => 'missing_uuid', 'MESSAGE' => 'UUID is required']); + if ($major <= 0) apiAbort(['OK' => false, 'ERROR' => 'missing_major', 'MESSAGE' => 'Major is required']); + + $resolved = resolveSingleBiz($uuid, $major); + if (!$resolved['Found']) { + apiAbort(['OK' => false, 'ERROR' => 'not_found', 'MESSAGE' => 'No business found for this beacon']); + } + + jsonResponse([ + 'OK' => true, + 'BusinessID' => $resolved['BusinessID'], + 'BusinessName' => $resolved['BusinessName'], + 'BrandColor' => $resolved['BrandColor'], + 'HeaderImageURL' => $resolved['HeaderImageURL'], + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/beacon-sharding/resolve_servicepoint.php b/api/beacon-sharding/resolve_servicepoint.php new file mode 100644 index 0000000..b141baa --- /dev/null +++ b/api/beacon-sharding/resolve_servicepoint.php @@ -0,0 +1,119 @@ + false, 'Error' => 'business_not_found']; + } + + $qSP = queryOne(" + SELECT ID, Name, Code, TypeID, Description + FROM ServicePoints + WHERE BusinessID = ? AND BeaconMinor = ? AND IsActive = 1 + LIMIT 1 + ", [(int) $qBiz['BusinessID'], $minor]); + + if (!$qSP) { + return [ + 'Found' => false, 'Error' => 'servicepoint_not_found', + 'BusinessID' => (int) $qBiz['BusinessID'], + 'BusinessName' => $qBiz['BusinessName'], + ]; + } + + return [ + 'Found' => true, + 'ServicePointID' => (int) $qSP['ID'], + 'ServicePointName' => $qSP['Name'], + 'ServicePointCode' => $qSP['Code'] ?? '', + 'ServicePointTypeID' => (int) $qSP['TypeID'], + 'ServicePointDescription' => $qSP['Description'] ?? '', + 'BusinessID' => (int) $qBiz['BusinessID'], + 'BusinessName' => $qBiz['BusinessName'], + ]; +} + +try { + $data = readJsonBody(); + + // Batch request + if (isset($data['Beacons']) && is_array($data['Beacons'])) { + $results = []; + foreach ($data['Beacons'] as $beacon) { + $uuid = trim($beacon['UUID'] ?? ''); + $major = isset($beacon['Major']) && is_numeric($beacon['Major']) ? (int) $beacon['Major'] : 0; + $minor = isset($beacon['Minor']) && is_numeric($beacon['Minor']) ? (int) $beacon['Minor'] : -1; + + if ($uuid === '' || $major <= 0 || $minor < 0) { + $results[] = ['UUID' => $uuid, 'Major' => $major, 'Minor' => $minor, 'ServicePointID' => null, 'Error' => 'invalid_params']; + continue; + } + + $resolved = resolveSingle($uuid, $major, $minor); + if ($resolved['Found']) { + $results[] = [ + 'UUID' => $uuid, 'Major' => $major, 'Minor' => $minor, + 'ServicePointID' => $resolved['ServicePointID'], + 'ServicePointName' => $resolved['ServicePointName'], + 'ServicePointCode' => $resolved['ServicePointCode'], + 'BusinessID' => $resolved['BusinessID'], + 'BusinessName' => $resolved['BusinessName'], + ]; + } else { + $errResult = ['UUID' => $uuid, 'Major' => $major, 'Minor' => $minor, 'ServicePointID' => null, 'Error' => $resolved['Error']]; + if (isset($resolved['BusinessID'])) { + $errResult['BusinessID'] = $resolved['BusinessID']; + $errResult['BusinessName'] = $resolved['BusinessName']; + } + $results[] = $errResult; + } + } + jsonResponse(['OK' => true, 'COUNT' => count($results), 'Results' => $results]); + } + + // Single request + $uuid = trim($data['UUID'] ?? ($_GET['UUID'] ?? '')); + $major = (int) ($data['Major'] ?? ($_GET['Major'] ?? 0)); + $minor = isset($data['Minor']) ? (int) $data['Minor'] : (isset($_GET['Minor']) ? (int) $_GET['Minor'] : -1); + + if ($uuid === '') apiAbort(['OK' => false, 'ERROR' => 'missing_uuid', 'MESSAGE' => 'UUID is required']); + if ($major <= 0) apiAbort(['OK' => false, 'ERROR' => 'missing_major', 'MESSAGE' => 'Major is required']); + if ($minor < 0) apiAbort(['OK' => false, 'ERROR' => 'missing_minor', 'MESSAGE' => 'Minor is required']); + + $resolved = resolveSingle($uuid, $major, $minor); + + if (!$resolved['Found']) { + $err = ['OK' => false, 'ERROR' => $resolved['Error']]; + if (isset($resolved['BusinessID'])) { + $err['BusinessID'] = $resolved['BusinessID']; + $err['BusinessName'] = $resolved['BusinessName']; + $err['MESSAGE'] = 'Service point not found for this business'; + } else { + $err['MESSAGE'] = 'No business found for this beacon'; + } + apiAbort($err); + } + + jsonResponse([ + 'OK' => true, + 'ServicePointID' => $resolved['ServicePointID'], + 'ServicePointName' => $resolved['ServicePointName'], + 'ServicePointCode' => $resolved['ServicePointCode'], + 'ServicePointTypeID' => $resolved['ServicePointTypeID'], + 'ServicePointDescription' => $resolved['ServicePointDescription'], + 'BusinessID' => $resolved['BusinessID'], + 'BusinessName' => $resolved['BusinessName'], + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/beacon-sharding/verify_beacon_broadcast.php b/api/beacon-sharding/verify_beacon_broadcast.php new file mode 100644 index 0000000..17aed21 --- /dev/null +++ b/api/beacon-sharding/verify_beacon_broadcast.php @@ -0,0 +1,68 @@ + false, 'ERROR' => 'missing_hardware_id', 'MESSAGE' => 'HardwareId is required']); + +$beaconUUID = trim($data['UUID'] ?? ''); +if ($beaconUUID === '') apiAbort(['OK' => false, 'ERROR' => 'missing_uuid', 'MESSAGE' => 'UUID is required']); + +$major = (int) ($data['Major'] ?? 0); +$minor = (int) ($data['Minor'] ?? 0); +$rssi = isset($data['RSSI']) && is_numeric($data['RSSI']) ? (int) $data['RSSI'] : null; + +$seenAt = date('Y-m-d H:i:s'); +if (!empty($data['SeenAt'])) { + try { $seenAt = (new DateTime($data['SeenAt']))->format('Y-m-d H:i:s'); } catch (Exception $e) {} +} + +try { + $qHW = queryOne(" + SELECT ID, BusinessID, ServicePointID, ShardUUID, Major, Minor, Status + FROM BeaconHardware WHERE HardwareId = ? LIMIT 1 + ", [$hardwareId]); + + if (!$qHW) { + apiAbort(['OK' => false, 'ERROR' => 'hardware_not_found', 'MESSAGE' => 'Beacon hardware not registered']); + } + + if (strcasecmp($qHW['ShardUUID'], $beaconUUID) !== 0) { + apiAbort(['OK' => false, 'ERROR' => 'uuid_mismatch', 'MESSAGE' => 'Beacon is broadcasting wrong UUID', + 'ExpectedUUID' => $qHW['ShardUUID'], 'BroadcastUUID' => $beaconUUID]); + } + if ((int) $qHW['Major'] !== $major) { + apiAbort(['OK' => false, 'ERROR' => 'major_mismatch', 'MESSAGE' => 'Beacon is broadcasting wrong Major', + 'ExpectedMajor' => (int) $qHW['Major'], 'BroadcastMajor' => $major]); + } + if ((int) $qHW['Minor'] !== $minor) { + apiAbort(['OK' => false, 'ERROR' => 'minor_mismatch', 'MESSAGE' => 'Beacon is broadcasting wrong Minor', + 'ExpectedMinor' => (int) $qHW['Minor'], 'BroadcastMinor' => $minor]); + } + + $setClauses = ["Status = 'verified'", "VerifiedAt = ?", "LastSeenAt = ?", "UpdatedAt = NOW()"]; + $params = [$seenAt, $seenAt]; + + if ($rssi !== null) { + $setClauses[] = "LastRssi = ?"; + $params[] = $rssi; + } + + $params[] = $hardwareId; + queryTimed("UPDATE BeaconHardware SET " . implode(', ', $setClauses) . " WHERE HardwareId = ?", $params); + + jsonResponse([ + 'OK' => true, + 'BeaconHardwareID' => (int) $qHW['ID'], + 'HardwareId' => $hardwareId, + 'BusinessID' => (int) $qHW['BusinessID'], + 'ServicePointID' => (int) $qHW['ServicePointID'], + 'Status' => 'verified', + 'VerifiedAt' => (new DateTime($seenAt))->format('Y-m-d\TH:i:s\Z'), + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/beacons/delete.php b/api/beacons/delete.php new file mode 100644 index 0000000..d1f4e95 --- /dev/null +++ b/api/beacons/delete.php @@ -0,0 +1,32 @@ + false, 'ERROR' => 'no_business_selected']); +} + +$servicePointId = (int) ($data['ServicePointID'] ?? 0); +if ($servicePointId <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'missing_service_point_id', 'MESSAGE' => 'ServicePointID is required']); +} + +queryTimed(" + UPDATE ServicePoints SET BeaconMinor = NULL + WHERE ID = ? AND BusinessID = ? +", [$servicePointId, $bizId]); + +$qCheck = queryOne(" + SELECT ID FROM ServicePoints WHERE ID = ? AND BusinessID = ? LIMIT 1 +", [$servicePointId, $bizId]); + +if (!$qCheck) { + apiAbort(['OK' => false, 'ERROR' => 'not_found', 'DEBUG_ServicePointID' => $servicePointId, 'DEBUG_BusinessID' => $bizId]); +} + +jsonResponse(['OK' => true, 'ERROR' => '', 'ServicePointID' => $servicePointId]); diff --git a/api/beacons/get.php b/api/beacons/get.php new file mode 100644 index 0000000..e42c03d --- /dev/null +++ b/api/beacons/get.php @@ -0,0 +1,39 @@ + false, 'ERROR' => 'missing_beacon_id', 'MESSAGE' => 'BeaconID is required']); +} + +$q = queryOne(" + SELECT b.ID, b.BusinessID, b.Name, b.UUID, b.IsActive + FROM Beacons b + WHERE b.ID = ? + AND (b.BusinessID = ? OR EXISTS ( + SELECT 1 FROM lt_BeaconsID_BusinessesID lt + WHERE lt.BeaconID = b.ID AND lt.BusinessID = ? + )) + LIMIT 1 +", [$beaconID, $businessId, $businessId]); + +if (!$q) { + apiAbort(['OK' => false, 'ERROR' => 'not_found']); +} + +jsonResponse([ + 'OK' => true, + 'ERROR' => '', + 'BEACON' => [ + 'BeaconID' => (int) $q['ID'], + 'BusinessID' => (int) $q['BusinessID'], + 'Name' => $q['Name'], + 'UUID' => $q['UUID'], + 'IsActive' => (int) $q['IsActive'], + ], +]); diff --git a/api/beacons/getBusinessFromBeacon.php b/api/beacons/getBusinessFromBeacon.php new file mode 100644 index 0000000..d23be4f --- /dev/null +++ b/api/beacons/getBusinessFromBeacon.php @@ -0,0 +1,116 @@ + false, 'ERROR' => 'missing_beacon_id', 'MESSAGE' => 'BeaconID is required']); +} + +$qBeacon = queryOne(" + SELECT ID, Name, UUID, BusinessID FROM Beacons + WHERE ID = ? AND IsActive = 1 LIMIT 1 +", [$beaconId]); + +if (!$qBeacon) { + apiAbort(['OK' => false, 'ERROR' => 'not_found', 'MESSAGE' => 'Beacon not found or inactive']); +} + +// Get businesses assigned to this beacon (via service points or join table) +$qAssignments = queryTimed(" + SELECT sp.BusinessID, sp.ID AS ServicePointID, biz.Name AS BusinessName, + biz.ParentBusinessID, sp.Name AS ServicePointName + FROM ServicePoints sp + INNER JOIN Businesses biz ON biz.ID = sp.BusinessID + WHERE sp.BeaconID = ? AND sp.IsActive = 1 + + UNION + + SELECT lt.BusinessID, 0 AS ServicePointID, biz.Name AS BusinessName, + biz.ParentBusinessID, '' AS ServicePointName + FROM lt_BeaconsID_BusinessesID lt + INNER JOIN Businesses biz ON biz.ID = lt.BusinessID + WHERE lt.BeaconID = ? + AND lt.BusinessID NOT IN ( + SELECT sp2.BusinessID FROM ServicePoints sp2 + WHERE sp2.BeaconID = ? AND sp2.IsActive = 1 + ) + + ORDER BY ParentBusinessID IS NULL DESC, BusinessName ASC +", [$beaconId, $beaconId, $beaconId]); + +// Check if any assigned business is a parent +$parentBusinessID = 0; +foreach ($qAssignments as $a) { + $childCount = queryOne(" + SELECT COUNT(*) AS cnt FROM Businesses WHERE ParentBusinessID = ? + ", [(int) $a['BusinessID']]); + if ($childCount && (int) $childCount['cnt'] > 0) { + $parentBusinessID = (int) $a['BusinessID']; + break; + } +} + +$businesses = []; + +if ($parentBusinessID > 0) { + $qParent = queryOne(" + SELECT Name, HeaderImageExtension FROM Businesses WHERE ID = ? + ", [$parentBusinessID]); + + $qChildren = queryTimed(" + SELECT ID, Name, ParentBusinessID, HeaderImageExtension + FROM Businesses WHERE ParentBusinessID = ? ORDER BY Name ASC + ", [$parentBusinessID]); + + $firstAssignment = $qAssignments[0] ?? []; + foreach ($qChildren as $child) { + $businesses[] = [ + 'BusinessID' => (int) $child['ID'], + 'Name' => $child['Name'], + 'ServicePointID' => (int) ($firstAssignment['ServicePointID'] ?? 0), + 'ServicePointName' => $firstAssignment['ServicePointName'] ?? '', + 'IsParent' => false, + 'ParentBusinessID' => $parentBusinessID, + ]; + } +} else { + foreach ($qAssignments as $a) { + $businesses[] = [ + 'BusinessID' => (int) $a['BusinessID'], + 'Name' => $a['BusinessName'], + 'ServicePointID' => (int) $a['ServicePointID'], + 'ServicePointName' => $a['ServicePointName'], + 'IsParent' => empty($a['ParentBusinessID']) || (int) $a['ParentBusinessID'] === 0, + ]; + } +} + +$response = [ + 'OK' => true, + 'ERROR' => '', + 'BEACON' => [ + 'BeaconID' => (int) $qBeacon['ID'], + 'Name' => $qBeacon['Name'], + 'UUID' => $qBeacon['UUID'], + ], + 'BUSINESSES' => $businesses, + 'BUSINESS' => $businesses[0] ?? (object) [], + 'SERVICEPOINT' => !empty($businesses) ? [ + 'ServicePointID' => $businesses[0]['ServicePointID'], + 'Name' => $businesses[0]['ServicePointName'], + 'IsActive' => true, + ] : (object) [], +]; + +if ($parentBusinessID > 0) { + $response['PARENT'] = [ + 'BusinessID' => $parentBusinessID, + 'Name' => $qParent['Name'] ?? '', + 'HeaderImageExtension' => trim($qParent['HeaderImageExtension'] ?? ''), + ]; +} + +jsonResponse($response); diff --git a/api/beacons/list.php b/api/beacons/list.php new file mode 100644 index 0000000..5069c26 --- /dev/null +++ b/api/beacons/list.php @@ -0,0 +1,77 @@ + false, 'ERROR' => 'no_business_selected']); +} + +$onlyActive = true; +if (isset($data['onlyActive'])) { + $v = $data['onlyActive']; + if (is_bool($v)) $onlyActive = $v; + elseif (is_numeric($v)) $onlyActive = ((int) $v === 1); + elseif (is_string($v)) $onlyActive = (strtolower(trim($v)) === 'true'); +} + +$qBiz = queryOne(" + SELECT b.ID, b.Name, b.BeaconShardID, b.BeaconMajor, bs.UUID AS ShardUUID + FROM Businesses b + LEFT JOIN BeaconShards bs ON bs.ID = b.BeaconShardID + WHERE b.ID = ? + LIMIT 1 +", [$bizId]); + +if (!$qBiz) { + apiAbort(['OK' => false, 'ERROR' => 'business_not_found']); +} + +$hasShard = ((int) ($qBiz['BeaconShardID'] ?? 0)) > 0; +$shardInfo = [ + 'ShardID' => $hasShard ? (int) $qBiz['BeaconShardID'] : 0, + 'ShardUUID' => $hasShard ? $qBiz['ShardUUID'] : '', + 'Major' => $hasShard ? (int) $qBiz['BeaconMajor'] : 0, +]; + +$sql = " + SELECT sp.ID AS ServicePointID, sp.Name, sp.BeaconMinor, sp.IsActive, sp.TypeID + FROM ServicePoints sp + WHERE sp.BusinessID = ? AND sp.BeaconMinor IS NOT NULL +"; +$params = [$bizId]; +if ($onlyActive) { + $sql .= " AND sp.IsActive = 1"; +} +$sql .= " ORDER BY sp.BeaconMinor, sp.Name"; + +$rows = queryTimed($sql, $params); + +$beacons = []; +foreach ($rows as $r) { + $beacons[] = [ + 'ServicePointID' => (int) $r['ServicePointID'], + 'BusinessID' => $bizId, + 'Name' => $r['Name'], + 'UUID' => $shardInfo['ShardUUID'], + 'Major' => $shardInfo['Major'], + 'Minor' => (int) $r['BeaconMinor'], + 'IsActive' => (bool) $r['IsActive'], + 'TypeID' => (int) $r['TypeID'], + ]; +} + +jsonResponse([ + 'OK' => true, + 'ERROR' => '', + 'BusinessID' => $bizId, + 'BusinessName' => $qBiz['Name'], + 'COUNT' => count($beacons), + 'BEACONS' => $beacons, + 'HAS_SHARD' => $hasShard, + 'SHARD_INFO' => $shardInfo, +]); diff --git a/api/beacons/list_all.php b/api/beacons/list_all.php new file mode 100644 index 0000000..9e63979 --- /dev/null +++ b/api/beacons/list_all.php @@ -0,0 +1,22 @@ + (int) $r['ID'], + 'UUID' => $r['UUID'], + ]; +} + +jsonResponse([ + 'OK' => true, + 'ERROR' => '', + 'SHARDS' => $items, + 'ITEMS' => $items, +]); diff --git a/api/beacons/lookup.php b/api/beacons/lookup.php new file mode 100644 index 0000000..b142b7a --- /dev/null +++ b/api/beacons/lookup.php @@ -0,0 +1,75 @@ + true, 'ERROR' => '', 'BEACONS' => []]); + } + + $beacons = []; + + foreach ($data['Beacons'] as $beaconData) { + $uuid = ''; + $major = 0; + $minor = -1; + + if (!empty($beaconData['UUID'])) { + $rawUuid = strtoupper(str_replace('-', '', $beaconData['UUID'])); + if (strlen($rawUuid) === 32) { + $uuid = strtolower( + substr($rawUuid, 0, 8) . '-' . substr($rawUuid, 8, 4) . '-' . + substr($rawUuid, 12, 4) . '-' . substr($rawUuid, 16, 4) . '-' . + substr($rawUuid, 20, 12) + ); + } + } + + if (isset($beaconData['Major']) && is_numeric($beaconData['Major'])) { + $major = (int) $beaconData['Major']; + } + if (isset($beaconData['Minor']) && is_numeric($beaconData['Minor'])) { + $minor = (int) $beaconData['Minor']; + } + + if ($uuid === '' || $major < 0 || $minor < 0) continue; + + $qShard = queryOne(" + SELECT + biz.ID AS BusinessID, biz.Name AS BusinessName, + biz.ParentBusinessID, + COALESCE(parent.Name, '') AS ParentBusinessName, + sp.ID AS ServicePointID, sp.Name AS ServicePointName, + (SELECT COUNT(*) FROM Businesses WHERE ParentBusinessID = biz.ID) AS ChildCount + FROM BeaconShards bs + JOIN Businesses biz ON biz.BeaconShardID = bs.ID AND biz.BeaconMajor = ? + LEFT JOIN ServicePoints sp ON sp.BusinessID = biz.ID AND sp.BeaconMinor = ? AND sp.IsActive = 1 + LEFT JOIN Businesses parent ON biz.ParentBusinessID = parent.ID + WHERE bs.UUID = ? AND bs.IsActive = 1 + AND biz.IsDemo = 0 AND biz.IsPrivate = 0 + LIMIT 1 + ", [$major, $minor, $uuid]); + + if ($qShard) { + $beacons[] = [ + 'UUID' => strtoupper(str_replace('-', '', $uuid)), + 'Major' => $major, + 'Minor' => $minor, + 'BusinessID' => (int) $qShard['BusinessID'], + 'BusinessName' => $qShard['BusinessName'], + 'ServicePointID' => (int) ($qShard['ServicePointID'] ?? 0), + 'ServicePointName' => $qShard['ServicePointName'] ?? '', + 'ParentBusinessID' => (int) ($qShard['ParentBusinessID'] ?? 0), + 'ParentBusinessName' => $qShard['ParentBusinessName'], + 'HasChildren' => (int) $qShard['ChildCount'] > 0, + ]; + } + } + + jsonResponse(['OK' => true, 'ERROR' => '', 'BEACONS' => $beacons]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => $e->getMessage()]); +} diff --git a/api/beacons/reassign_all.php b/api/beacons/reassign_all.php new file mode 100644 index 0000000..85b2a3b --- /dev/null +++ b/api/beacons/reassign_all.php @@ -0,0 +1,19 @@ + true, + 'MESSAGE' => "All beacons reassigned to BusinessID $targetBusinessID", + 'COUNT' => (int) $qCount['cnt'], + ]); +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => $e->getMessage()]); +} diff --git a/api/beacons/save.php b/api/beacons/save.php new file mode 100644 index 0000000..d8ca11e --- /dev/null +++ b/api/beacons/save.php @@ -0,0 +1,103 @@ + false, 'ERROR' => 'no_business_selected']); +} + +$qBiz = queryOne(" + SELECT ID, Name, BeaconShardID, BeaconMajor FROM Businesses WHERE ID = ? LIMIT 1 +", [$businessId]); + +if (!$qBiz) { + apiAbort(['OK' => false, 'ERROR' => 'invalid_business', 'MESSAGE' => 'Business not found']); +} + +$shardID = (int) ($qBiz['BeaconShardID'] ?? 0); +$major = (int) ($qBiz['BeaconMajor'] ?? 0); + +// Auto-allocate shard if needed +if ($shardID <= 0) { + $qFreeShard = queryOne(" + SELECT bs.ID FROM BeaconShards bs + WHERE bs.IsActive = 1 + AND bs.ID NOT IN (SELECT BeaconShardID FROM Businesses WHERE BeaconShardID IS NOT NULL) + ORDER BY bs.ID LIMIT 1 + ", []); + + if (!$qFreeShard) { + apiAbort(['OK' => false, 'ERROR' => 'no_shards_available', 'MESSAGE' => 'No beacon shards available']); + } + + $shardID = (int) $qFreeShard['ID']; + + $qMaxMajor = queryOne(" + SELECT COALESCE(MAX(BeaconMajor), 0) AS MaxMajor FROM Businesses WHERE BeaconShardID = ? + ", [$shardID]); + $major = (int) $qMaxMajor['MaxMajor'] + 1; + + queryTimed("UPDATE Businesses SET BeaconShardID = ?, BeaconMajor = ? WHERE ID = ?", + [$shardID, $major, $businessId]); +} + +$qShard = queryOne("SELECT UUID FROM BeaconShards WHERE ID = ?", [$shardID]); +$shardUUID = $qShard['UUID']; + +// Service point handling +$spName = trim($data['Name'] ?? ''); +if ($spName === '') { + apiAbort(['OK' => false, 'ERROR' => 'missing_name', 'MESSAGE' => 'Service point name is required']); +} + +$servicePointID = (int) ($data['ServicePointID'] ?? 0); + +if ($servicePointID > 0) { + $qSP = queryOne(" + SELECT ID, BeaconMinor FROM ServicePoints WHERE ID = ? AND BusinessID = ? LIMIT 1 + ", [$servicePointID, $businessId]); + + if (!$qSP) { + apiAbort(['OK' => false, 'ERROR' => 'invalid_service_point', 'MESSAGE' => 'Service point not found']); + } + + $minor = $qSP['BeaconMinor']; + if ($minor === null) { + $qMaxMinor = queryOne(" + SELECT COALESCE(MAX(BeaconMinor), 0) AS MaxMinor FROM ServicePoints WHERE BusinessID = ? + ", [$businessId]); + $minor = (int) $qMaxMinor['MaxMinor'] + 1; + } + + queryTimed("UPDATE ServicePoints SET Name = ?, BeaconMinor = ?, IsActive = 1 WHERE ID = ?", + [$spName, $minor, $servicePointID]); +} else { + $qMaxMinor = queryOne(" + SELECT COALESCE(MAX(BeaconMinor), 0) AS MaxMinor FROM ServicePoints WHERE BusinessID = ? + ", [$businessId]); + $minor = (int) $qMaxMinor['MaxMinor'] + 1; + + queryTimed(" + INSERT INTO ServicePoints (BusinessID, Name, TypeID, IsActive, BeaconMinor, SortOrder) + VALUES (?, ?, 1, 1, ?, ?) + ", [$businessId, $spName, $minor, $minor]); + + $servicePointID = (int) lastInsertId(); +} + +jsonResponse([ + 'OK' => true, + 'ERROR' => '', + 'ServicePointID' => $servicePointID, + 'ServicePointName' => $spName, + 'BusinessID' => $businessId, + 'BusinessName' => $qBiz['Name'], + 'ShardID' => $shardID, + 'UUID' => $shardUUID, + 'Major' => $major, + 'Minor' => (int) $minor, +]); diff --git a/api/businesses/get.php b/api/businesses/get.php new file mode 100644 index 0000000..8e983fa --- /dev/null +++ b/api/businesses/get.php @@ -0,0 +1,150 @@ + false, 'ERROR' => 'BusinessID is required']); +} + +try { + $q = queryOne(" + SELECT + ID, Name, Phone, StripeAccountID, StripeOnboardingComplete, + IsHiring, HeaderImageExtension, TaxRate, BrandColor, BrandColorLight, + SessionEnabled, SessionLockMinutes, SessionPaymentStrategy, + TabMinAuthAmount, TabDefaultAuthAmount, TabMaxAuthAmount, + TabAutoIncreaseThreshold, TabMaxMembers, TabApprovalRequired, + OrderTypes + FROM Businesses + WHERE ID = ? + ", [$businessID]); + + if (!$q) { + apiAbort(['OK' => false, 'ERROR' => 'Business not found']); + } + + // Get address + $qAddr = queryOne(" + SELECT a.Line1 AS AddressLine1, a.Line2 AS AddressLine2, + a.City AS AddressCity, a.ZIPCode AS AddressZIPCode, + s.Abbreviation + FROM Addresses a + LEFT JOIN tt_States s ON s.ID = a.StateID + WHERE (a.BusinessID = ? OR a.ID = (SELECT AddressID FROM Businesses WHERE ID = ?)) + AND a.IsDeleted = 0 + LIMIT 1 + ", [$businessID, $businessID]); + + $addressStr = ''; + $addressLine1 = ''; + $addressCity = ''; + $addressState = ''; + $addressZip = ''; + + if ($qAddr) { + $addressLine1 = $qAddr['AddressLine1'] ?? ''; + $addressCity = $qAddr['AddressCity'] ?? ''; + $addressState = $qAddr['Abbreviation'] ?? ''; + $addressZip = $qAddr['AddressZIPCode'] ?? ''; + + $parts = []; + if (!empty($qAddr['AddressLine1'])) $parts[] = $qAddr['AddressLine1']; + if (!empty($qAddr['AddressLine2'])) $parts[] = $qAddr['AddressLine2']; + $csz = []; + if (!empty($qAddr['AddressCity'])) $csz[] = $qAddr['AddressCity']; + if (!empty($qAddr['Abbreviation'])) $csz[] = $qAddr['Abbreviation']; + if (!empty($qAddr['AddressZIPCode'])) $csz[] = $qAddr['AddressZIPCode']; + if ($csz) $parts[] = implode(', ', $csz); + $addressStr = implode(', ', $parts); + } + + // Get hours + $qHours = queryTimed(" + SELECT h.DayID, h.OpenTime, h.ClosingTime, d.Abbrev + FROM Hours h + JOIN tt_Days d ON d.ID = h.DayID + WHERE h.BusinessID = ? + ORDER BY h.DayID + ", [$businessID]); + + $hoursArr = []; + $hoursStr = ''; + if ($qHours) { + foreach ($qHours as $h) { + $open = (new DateTime($h['OpenTime']))->format('g:i A'); + $close = (new DateTime($h['ClosingTime']))->format('g:i A'); + $hoursArr[] = [ + 'day' => $h['Abbrev'], + 'dayId' => (int) $h['DayID'], + 'open' => $open, + 'close' => $close, + ]; + } + // Group similar hours + $hourGroups = []; + foreach ($hoursArr as $h) { + $key = $h['open'] . '-' . $h['close']; + $hourGroups[$key][] = $h['day']; + } + $hourStrParts = []; + foreach ($hourGroups as $key => $days) { + $hourStrParts[] = implode(',', $days) . ': ' . $key; + } + $hoursStr = implode(', ', $hourStrParts); + } + + // Prefix hex colors with # + $brandColor = $q['BrandColor'] ?? ''; + if ($brandColor !== '' && $brandColor[0] !== '#') $brandColor = '#' . $brandColor; + + $brandColorLight = $q['BrandColorLight'] ?? ''; + if ($brandColorLight !== '' && $brandColorLight[0] !== '#') $brandColorLight = '#' . $brandColorLight; + + $taxRate = is_numeric($q['TaxRate']) ? (float) $q['TaxRate'] : 0; + + $business = [ + 'BusinessID' => (int) $q['ID'], + 'Name' => $q['Name'], + 'Address' => $addressStr, + 'Line1' => $addressLine1, + 'City' => $addressCity, + 'AddressState' => $addressState, + 'AddressZip' => $addressZip, + 'Phone' => $q['Phone'] ?? '', + 'Hours' => $hoursStr, + 'HoursDetail' => $hoursArr, + 'StripeConnected' => (!empty($q['StripeAccountID']) && ($q['StripeOnboardingComplete'] ?? 0) == 1), + 'IsHiring' => ($q['IsHiring'] ?? 0) == 1, + 'TaxRate' => $taxRate, + 'TaxRatePercent' => $taxRate * 100, + 'BrandColor' => $brandColor, + 'BrandColorLight' => $brandColorLight, + 'SessionEnabled' => (int) ($q['SessionEnabled'] ?? 0), + 'SessionLockMinutes' => (int) ($q['SessionLockMinutes'] ?? 30), + 'SessionPaymentStrategy' => !empty($q['SessionPaymentStrategy']) ? $q['SessionPaymentStrategy'] : 'A', + 'TabMinAuthAmount' => (float) ($q['TabMinAuthAmount'] ?? 50.00), + 'TabDefaultAuthAmount' => (float) ($q['TabDefaultAuthAmount'] ?? 150.00), + 'TabMaxAuthAmount' => (float) ($q['TabMaxAuthAmount'] ?? 1000.00), + 'TabAutoIncreaseThreshold' => (float) ($q['TabAutoIncreaseThreshold'] ?? 0.80), + 'TabMaxMembers' => (int) ($q['TabMaxMembers'] ?? 10), + 'TabApprovalRequired' => (int) ($q['TabApprovalRequired'] ?? 1), + 'OrderTypes' => !empty($q['OrderTypes']) ? $q['OrderTypes'] : '1', + ]; + + if (!empty($q['HeaderImageExtension'])) { + $business['HeaderImageURL'] = '/uploads/headers/' . $q['ID'] . '.' . $q['HeaderImageExtension']; + } + + jsonResponse(['OK' => true, 'BUSINESS' => $business]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => $e->getMessage()]); +} diff --git a/api/businesses/getChildren.php b/api/businesses/getChildren.php new file mode 100644 index 0000000..d4cd6ad --- /dev/null +++ b/api/businesses/getChildren.php @@ -0,0 +1,44 @@ + false, 'ERROR' => 'missing_business_id', 'MESSAGE' => 'BusinessID is required']); +} + +try { + $rows = queryTimed(" + SELECT ID, Name + FROM Businesses + WHERE ParentBusinessID = ? + ORDER BY Name + ", [$parentBusinessId]); + + $businesses = []; + foreach ($rows as $r) { + $businesses[] = [ + 'BusinessID' => (int) $r['ID'], + 'Name' => $r['Name'], + ]; + } + + jsonResponse(['OK' => true, 'ERROR' => '', 'BUSINESSES' => $businesses]); + +} catch (Exception $e) { + apiAbort(['OK' => false, 'ERROR' => 'server_error', 'DETAIL' => $e->getMessage()]); +} diff --git a/api/businesses/list.php b/api/businesses/list.php new file mode 100644 index 0000000..efeaf03 --- /dev/null +++ b/api/businesses/list.php @@ -0,0 +1,83 @@ + (int) $r['ID'], + 'Name' => $r['Name'], + 'HasChildren' => ((int) $r['ChildCount']) > 0, + 'City' => $r['AddressCity'] ?? '', + 'Line1' => $r['AddressLine1'] ?? '', + 'Latitude' => $bizLat, + 'Longitude' => $bizLng, + 'DistanceMiles' => $distance, + ]; + } + + // Sort by distance if user location provided + if ($hasUserLocation) { + usort($businesses, fn($a, $b) => $a['DistanceMiles'] <=> $b['DistanceMiles']); + } + + // Limit to 50 + $businesses = array_slice($businesses, 0, 50); + + jsonResponse([ + 'OK' => true, + 'ERROR' => '', + 'VERSION' => 'businesses_list_v5', + 'BUSINESSES' => $businesses, + 'Businesses' => $businesses, + ]); + +} catch (Exception $e) { + apiAbort(['OK' => false, 'ERROR' => 'server_error', 'DETAIL' => $e->getMessage()]); +} diff --git a/api/businesses/saveBrandColor.php b/api/businesses/saveBrandColor.php new file mode 100644 index 0000000..a61c642 --- /dev/null +++ b/api/businesses/saveBrandColor.php @@ -0,0 +1,49 @@ + false, 'ERROR' => 'invalid_color', 'MESSAGE' => 'Color must be a valid 6-digit hex']); + } + return strtoupper($c); +} + +try { + $data = readJsonBody(); + if (empty($data)) { + apiAbort(['OK' => false, 'ERROR' => 'no_body', 'MESSAGE' => 'No request body provided']); + } + + $bizId = (int) ($data['BusinessID'] ?? 0); + if ($bizId <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'missing_businessid', 'MESSAGE' => 'BusinessID is required']); + } + + $brandColor = normalizeHex($data['BrandColor'] ?? ''); + $brandColorLight = normalizeHex($data['BrandColorLight'] ?? ''); + + queryTimed(" + UPDATE Businesses SET BrandColor = ?, BrandColorLight = ? + WHERE ID = ? + ", [$brandColor, $brandColorLight, $bizId]); + + jsonResponse([ + 'OK' => true, + 'ERROR' => '', + 'MESSAGE' => 'Brand color saved', + 'BRANDCOLOR' => $brandColor, + 'BRANDCOLORLIGHT' => $brandColorLight, + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/businesses/saveOrderTypes.php b/api/businesses/saveOrderTypes.php new file mode 100644 index 0000000..257c7ea --- /dev/null +++ b/api/businesses/saveOrderTypes.php @@ -0,0 +1,35 @@ + false, 'ERROR' => 'No request body provided']); + } + + $businessId = (int) ($data['BusinessID'] ?? 0); + if ($businessId <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'BusinessID is required']); + } + + $orderTypes = trim($data['OrderTypes'] ?? '1'); + + // Validate: only allow digits 1-3 separated by commas + if (!preg_match('/^[1-3](,[1-3])*$/', $orderTypes)) { + apiAbort(['OK' => false, 'ERROR' => 'OrderTypes must be a comma-separated list of 1, 2, or 3']); + } + + queryTimed("UPDATE Businesses SET OrderTypes = ? WHERE ID = ?", [$orderTypes, $businessId]); + + jsonResponse(['OK' => true, 'OrderTypes' => $orderTypes]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => $e->getMessage()]); +} diff --git a/api/businesses/setHiring.php b/api/businesses/setHiring.php new file mode 100644 index 0000000..41f1da7 --- /dev/null +++ b/api/businesses/setHiring.php @@ -0,0 +1,30 @@ + false, 'ERROR' => 'missing_business_id']); +} + +if (!isset($data['IsHiring'])) { + apiAbort(['OK' => false, 'ERROR' => 'missing_is_hiring']); +} + +$isHiring = $data['IsHiring'] ? 1 : 0; + +try { + queryTimed("UPDATE Businesses SET IsHiring = ? WHERE ID = ?", [$isHiring, $businessId]); + + jsonResponse(['OK' => true, 'IsHiring' => $isHiring === 1]); + +} catch (Exception $e) { + apiAbort(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/businesses/update.php b/api/businesses/update.php new file mode 100644 index 0000000..c2898a9 --- /dev/null +++ b/api/businesses/update.php @@ -0,0 +1,85 @@ + false, 'ERROR' => 'No request body provided']); + } + + $businessId = (int) ($data['BusinessID'] ?? 0); + if ($businessId <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'BusinessID is required']); + } + + $bizName = trim($data['Name'] ?? ''); + $bizPhone = trim($data['Phone'] ?? ''); + + // Handle tax rate + $taxRate = null; + if (isset($data['TaxRatePercent']) && is_numeric($data['TaxRatePercent'])) { + $taxRate = $data['TaxRatePercent'] / 100; + } elseif (isset($data['TaxRate']) && is_numeric($data['TaxRate'])) { + $taxRate = (float) $data['TaxRate']; + } + + if ($bizName !== '') { + if ($taxRate !== null) { + queryTimed(" + UPDATE Businesses SET Name = ?, Phone = ?, TaxRate = ? + WHERE ID = ? + ", [$bizName, $bizPhone, $taxRate, $businessId]); + } else { + queryTimed(" + UPDATE Businesses SET Name = ?, Phone = ? + WHERE ID = ? + ", [$bizName, $bizPhone, $businessId]); + } + } + + // Update or create address + $line1 = trim($data['Line1'] ?? ''); + $city = trim($data['City'] ?? ''); + $state = trim($data['State'] ?? ''); + $zip = trim($data['Zip'] ?? ''); + + // Clean trailing punctuation from city + $city = preg_replace('/[,.\s]+$/', '', $city); + + // Get state ID + $stateID = 0; + if ($state !== '') { + $qState = queryOne("SELECT ID FROM tt_States WHERE Abbreviation = ?", [strtoupper($state)]); + if ($qState) $stateID = (int) $qState['ID']; + } + + // Check existing address + $qAddr = queryOne(" + SELECT ID FROM Addresses + WHERE BusinessID = ? AND UserID = 0 AND IsDeleted = 0 + LIMIT 1 + ", [$businessId]); + + if ($qAddr) { + queryTimed(" + UPDATE Addresses SET Line1 = ?, City = ?, StateID = ?, ZIPCode = ? + WHERE ID = ? + ", [$line1, $city, $stateID, $zip, $qAddr['ID']]); + } else { + queryTimed(" + INSERT INTO Addresses (Line1, City, StateID, ZIPCode, BusinessID, UserID, AddressTypeID, AddedOn) + VALUES (?, ?, ?, ?, ?, 0, 2, NOW()) + ", [$line1, $city, $stateID, $zip, $businessId]); + } + + jsonResponse(['OK' => true]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => $e->getMessage()]); +} diff --git a/api/businesses/updateHours.php b/api/businesses/updateHours.php new file mode 100644 index 0000000..c7868b9 --- /dev/null +++ b/api/businesses/updateHours.php @@ -0,0 +1,50 @@ + false, 'ERROR' => 'No request body provided']); + } + + $businessId = (int) ($data['BusinessID'] ?? 0); + if ($businessId <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'BusinessID is required']); + } + + $hours = $data['Hours'] ?? []; + if (!is_array($hours)) $hours = []; + + // Delete all existing hours + queryTimed("DELETE FROM Hours WHERE BusinessID = ?", [$businessId]); + + // Insert new hours + foreach ($hours as $h) { + if (!is_array($h)) continue; + $dayId = (int) ($h['dayId'] ?? 0); + $open = $h['open'] ?? '09:00'; + $close = $h['close'] ?? '17:00'; + + if ($dayId >= 1 && $dayId <= 7) { + if (strlen($open) === 5) $open .= ':00'; + if (strlen($close) === 5) $close .= ':00'; + + queryTimed(" + INSERT INTO Hours (BusinessID, DayID, OpenTime, ClosingTime) + VALUES (?, ?, ?, ?) + ", [$businessId, $dayId, $open, $close]); + } + } + + jsonResponse(['OK' => true, 'hoursUpdated' => count($hours)]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => $e->getMessage()]); +} diff --git a/api/businesses/updateTabs.php b/api/businesses/updateTabs.php new file mode 100644 index 0000000..8cab156 --- /dev/null +++ b/api/businesses/updateTabs.php @@ -0,0 +1,72 @@ + false, 'ERROR' => 'missing_body']); +} + +$businessID = (int) ($data['BusinessID'] ?? 0); +if ($businessID <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'missing_BusinessID']); +} + +$sessionEnabled = (int) ($data['SessionEnabled'] ?? 0); +$sessionLockMinutes = (int) ($data['SessionLockMinutes'] ?? 30); +$sessionPaymentStrategy = substr(trim($data['SessionPaymentStrategy'] ?? 'A'), 0, 1); +$tabMinAuth = (float) ($data['TabMinAuthAmount'] ?? 50.00); +$tabDefaultAuth = (float) ($data['TabDefaultAuthAmount'] ?? 150.00); +$tabMaxAuth = (float) ($data['TabMaxAuthAmount'] ?? 1000.00); +$tabAutoThreshold = (float) ($data['TabAutoIncreaseThreshold'] ?? 0.80); +$tabMaxMembers = (int) ($data['TabMaxMembers'] ?? 10); +$tabApprovalRequired = (int) ($data['TabApprovalRequired'] ?? 1); + +// Validate ranges +$sessionLockMinutes = max(5, min(480, $sessionLockMinutes)); +if (!in_array($sessionPaymentStrategy, ['A', 'P'])) $sessionPaymentStrategy = 'A'; +$tabMinAuth = max(10, min(10000, $tabMinAuth)); +$tabMaxAuth = max($tabMinAuth, min(10000, $tabMaxAuth)); +$tabDefaultAuth = max($tabMinAuth, min($tabMaxAuth, $tabDefaultAuth)); +$tabAutoThreshold = max(0.5, min(1.0, $tabAutoThreshold)); +$tabMaxMembers = max(1, min(50, $tabMaxMembers)); + +try { + queryTimed(" + UPDATE Businesses SET + SessionEnabled = ?, SessionLockMinutes = ?, SessionPaymentStrategy = ?, + TabMinAuthAmount = ?, TabDefaultAuthAmount = ?, TabMaxAuthAmount = ?, + TabAutoIncreaseThreshold = ?, TabMaxMembers = ?, TabApprovalRequired = ? + WHERE ID = ? + ", [ + $sessionEnabled, $sessionLockMinutes, $sessionPaymentStrategy, + $tabMinAuth, $tabDefaultAuth, $tabMaxAuth, + $tabAutoThreshold, $tabMaxMembers, $tabApprovalRequired, + $businessID + ]); + + jsonResponse([ + 'OK' => true, + 'ERROR' => '', + 'BusinessID' => $businessID, + 'SessionEnabled' => $sessionEnabled, + 'SessionLockMinutes' => $sessionLockMinutes, + 'SessionPaymentStrategy' => $sessionPaymentStrategy, + 'TabMinAuthAmount' => $tabMinAuth, + 'TabDefaultAuthAmount' => $tabDefaultAuth, + 'TabMaxAuthAmount' => $tabMaxAuth, + 'TabAutoIncreaseThreshold' => $tabAutoThreshold, + 'TabMaxMembers' => $tabMaxMembers, + 'TabApprovalRequired' => $tabApprovalRequired, + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/chat/closeChat.php b/api/chat/closeChat.php new file mode 100644 index 0000000..081682e --- /dev/null +++ b/api/chat/closeChat.php @@ -0,0 +1,30 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'TaskID is required']); +} + +try { + queryTimed(" + UPDATE Tasks + SET CompletedOn = NOW() + WHERE ID = ? + AND TaskTypeID = 2 + AND CompletedOn IS NULL + ", [$taskID]); + + jsonResponse(['OK' => true, 'MESSAGE' => 'Chat closed']); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/chat/getActiveChat.php b/api/chat/getActiveChat.php new file mode 100644 index 0000000..cf318da --- /dev/null +++ b/api/chat/getActiveChat.php @@ -0,0 +1,61 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'BusinessID is required']); +} + +if ($servicePointID <= 0) { + jsonResponse([ + 'OK' => true, + 'HAS_ACTIVE_CHAT' => false, + 'TASK_ID' => 0, + 'TASK_TITLE' => '', + ]); +} + +try { + $qChat = queryOne(" + SELECT t.ID, t.Title, + (SELECT MAX(cm.CreatedOn) FROM ChatMessages cm WHERE cm.TaskID = t.ID) as LastMessageTime + FROM Tasks t + WHERE t.BusinessID = ? + AND t.TaskTypeID = 2 + AND t.CompletedOn IS NULL + AND t.SourceType = 'servicepoint' + AND t.SourceID = ? + ORDER BY + CASE WHEN t.ClaimedByUserID > 0 THEN 0 ELSE 1 END, + t.CreatedOn DESC + LIMIT 1 + ", [$businessID, $servicePointID]); + + if ($qChat) { + jsonResponse([ + 'OK' => true, + 'HAS_ACTIVE_CHAT' => true, + 'TASK_ID' => (int) $qChat['ID'], + 'TASK_TITLE' => $qChat['Title'], + ]); + } else { + jsonResponse([ + 'OK' => true, + 'HAS_ACTIVE_CHAT' => false, + 'TASK_ID' => 0, + 'TASK_TITLE' => '', + ]); + } + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/chat/getMessages.php b/api/chat/getMessages.php new file mode 100644 index 0000000..6532a6f --- /dev/null +++ b/api/chat/getMessages.php @@ -0,0 +1,74 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'TaskID is required']); +} + +try { + if ($afterMessageID > 0) { + $qMessages = queryTimed(" + SELECT + m.ID, m.TaskID, m.SenderUserID, m.SenderType, + m.MessageBody, m.IsRead, m.CreatedOn, + u.FirstName as SenderName + FROM ChatMessages m + LEFT JOIN Users u ON u.ID = m.SenderUserID + WHERE m.TaskID = ? AND m.ID > ? + ORDER BY m.CreatedOn ASC + ", [$taskID, $afterMessageID]); + } else { + $qMessages = queryTimed(" + SELECT + m.ID, m.TaskID, m.SenderUserID, m.SenderType, + m.MessageBody, m.IsRead, m.CreatedOn, + u.FirstName as SenderName + FROM ChatMessages m + LEFT JOIN Users u ON u.ID = m.SenderUserID + WHERE m.TaskID = ? + ORDER BY m.CreatedOn ASC + ", [$taskID]); + } + + $messages = []; + foreach ($qMessages as $msg) { + $senderName = !empty(trim($msg['SenderName'] ?? '')) + ? $msg['SenderName'] + : ($msg['SenderType'] === 'customer' ? 'Customer' : 'Staff'); + + $messages[] = [ + 'MessageID' => (int) $msg['ID'], + 'TaskID' => (int) $msg['TaskID'], + 'SenderUserID' => (int) $msg['SenderUserID'], + 'SenderType' => $msg['SenderType'], + 'SenderName' => $senderName, + 'Text' => $msg['MessageBody'], + 'IsRead' => ((int) $msg['IsRead']) === 1, + 'CreatedOn' => toISO8601($msg['CreatedOn']), + ]; + } + + // Check if chat/task is closed + $qTask = queryOne("SELECT CompletedOn FROM Tasks WHERE ID = ?", [$taskID]); + $chatClosed = ($qTask && !empty(trim($qTask['CompletedOn'] ?? ''))); + + jsonResponse([ + 'OK' => true, + 'MESSAGES' => $messages, + 'COUNT' => count($messages), + 'CHAT_CLOSED' => $chatClosed, + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/chat/markRead.php b/api/chat/markRead.php new file mode 100644 index 0000000..1029413 --- /dev/null +++ b/api/chat/markRead.php @@ -0,0 +1,36 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'TaskID is required']); +} + +if ($readerType !== 'customer' && $readerType !== 'worker') { + apiAbort(['OK' => false, 'ERROR' => 'missing_params', 'MESSAGE' => "ReaderType must be 'customer' or 'worker'"]); +} + +try { + $otherType = ($readerType === 'customer') ? 'worker' : 'customer'; + + queryTimed(" + UPDATE ChatMessages + SET IsRead = 1 + WHERE TaskID = ? AND SenderType = ? AND IsRead = 0 + ", [$taskID, $otherType]); + + jsonResponse(['OK' => true, 'MESSAGE' => 'Messages marked as read']); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/chat/sendMessage.php b/api/chat/sendMessage.php new file mode 100644 index 0000000..03ccbbf --- /dev/null +++ b/api/chat/sendMessage.php @@ -0,0 +1,63 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'TaskID is required']); +} +if (empty($message)) { + apiAbort(['OK' => false, 'ERROR' => 'missing_params', 'MESSAGE' => 'Message is required']); +} +if ($userID <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'missing_params', 'MESSAGE' => 'UserID is required']); +} + +if ($senderType !== 'customer' && $senderType !== 'worker') { + $senderType = 'customer'; +} + +try { + // Verify task exists and is still open + $taskQuery = queryOne(" + SELECT ID, ClaimedByUserID, CompletedOn FROM Tasks WHERE ID = ? + ", [$taskID]); + + if (!$taskQuery) { + apiAbort(['OK' => false, 'ERROR' => 'not_found', 'MESSAGE' => 'Task not found']); + } + + if (!empty(trim($taskQuery['CompletedOn'] ?? ''))) { + apiAbort(['OK' => false, 'ERROR' => 'chat_closed', 'MESSAGE' => 'This chat has ended']); + } + + // Insert message + queryTimed(" + INSERT INTO ChatMessages (TaskID, SenderUserID, SenderType, MessageBody) + VALUES (?, ?, ?, ?) + ", [$taskID, $userID, $senderType, $message]); + + $messageID = (int) lastInsertId(); + + jsonResponse([ + 'OK' => true, + 'MessageID' => $messageID, + 'MESSAGE' => 'Message sent', + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/config/stripe.php b/api/config/stripe.php new file mode 100644 index 0000000..b282081 --- /dev/null +++ b/api/config/stripe.php @@ -0,0 +1,100 @@ + $testSecretKey, + 'publishableKey' => $testPublishableKey, + 'webhookSecret' => $testWebhookSecret, + ]; + } + + return [ + 'secretKey' => $liveSecretKey, + 'publishableKey' => $livePublishableKey, + 'webhookSecret' => $liveWebhookSecret, + ]; +} + +/** + * Make a Stripe API request. + * + * @param string $method HTTP method (GET, POST, DELETE) + * @param string $url Full Stripe API URL + * @param array $params Form params for POST, ignored for GET + * @param array $headers Extra headers (e.g., Stripe-Version, Idempotency-Key) + * @return array Decoded JSON response + */ +function stripeRequest(string $method, string $url, array $params = [], array $headers = []): array { + $config = getStripeConfig(); + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_USERPWD, $config['secretKey'] . ':'); + + $curlHeaders = []; + foreach ($headers as $k => $v) { + $curlHeaders[] = "$k: $v"; + } + if (!empty($curlHeaders)) { + curl_setopt($ch, CURLOPT_HTTPHEADER, $curlHeaders); + } + + if ($method === 'POST') { + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($params)); + curl_setopt($ch, CURLOPT_URL, $url); + } elseif ($method === 'DELETE') { + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); + curl_setopt($ch, CURLOPT_URL, $url); + } else { + curl_setopt($ch, CURLOPT_URL, $url); + } + + $response = curl_exec($ch); + curl_close($ch); + + return json_decode($response, true) ?: []; +} + +/** + * Make a Stripe API POST and return raw response body (for ephemeral keys). + */ +function stripeRequestRaw(string $url, array $params = [], array $headers = []): string { + $config = getStripeConfig(); + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_USERPWD, $config['secretKey'] . ':'); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($params)); + curl_setopt($ch, CURLOPT_URL, $url); + + $curlHeaders = []; + foreach ($headers as $k => $v) { + $curlHeaders[] = "$k: $v"; + } + if (!empty($curlHeaders)) { + curl_setopt($ch, CURLOPT_HTTPHEADER, $curlHeaders); + } + + $response = curl_exec($ch); + curl_close($ch); + + return $response ?: ''; +} diff --git a/api/grants/_grantUtils.php b/api/grants/_grantUtils.php new file mode 100644 index 0000000..5ad4ea7 --- /dev/null +++ b/api/grants/_grantUtils.php @@ -0,0 +1,103 @@ +format('w') + 1; // 1=Sunday .. 7=Saturday + if (!in_array($todayDow, $policy['days'])) return false; + if (isset($policy['startTime'], $policy['endTime'])) { + $currentTime = $now->format('H:i'); + if ($currentTime < $policy['startTime'] || $currentTime > $policy['endTime']) return false; + } + return true; + + case 'date_range': + // policy: { start: "2026-03-01", end: "2026-06-30" } + if (!isset($policy['start'], $policy['end'])) return false; + $today = $now->format('Y-m-d'); + return ($today >= $policy['start'] && $today <= $policy['end']); + + case 'event': + // policy: { name: "...", start: "2026-07-04 10:00", end: "2026-07-04 22:00" } + if (!isset($policy['start'], $policy['end'])) return false; + $nowStr = $now->format('Y-m-d H:i'); + return ($nowStr >= $policy['start'] && $nowStr <= $policy['end']); + + default: + return false; + } +} + +/** + * Record a grant history entry. + */ +function recordGrantHistory( + int $grantID, + string $action, + int $actorUserID, + int $actorBusinessID, + array $previousData = [], + array $newData = [] +): void { + queryTimed( + "INSERT INTO ServicePointGrantHistory (GrantID, Action, ActorUserID, ActorBusinessID, PreviousData, NewData, CreatedOn) + VALUES (?, ?, ?, ?, ?, ?, NOW())", + [ + $grantID, + $action, + $actorUserID, + $actorBusinessID, + !empty($previousData) ? json_encode($previousData) : null, + !empty($newData) ? json_encode($newData) : null, + ] + ); +} + +/** + * Check if a user meets the eligibility scope for a grant. + */ +function checkGrantEligibility(string $eligibilityScope, int $userID, int $ownerBusinessID, int $guestBusinessID): bool { + if ($eligibilityScope === 'public') return true; + + $isGuestEmployee = (bool) queryOne( + "SELECT 1 FROM Employees WHERE BusinessID = ? AND UserID = ? AND IsActive = 1 LIMIT 1", + [$guestBusinessID, $userID] + ); + + $isOwnerEmployee = (bool) queryOne( + "SELECT 1 FROM Employees WHERE BusinessID = ? AND UserID = ? AND IsActive = 1 LIMIT 1", + [$ownerBusinessID, $userID] + ); + + switch ($eligibilityScope) { + case 'employees': return $isGuestEmployee; + case 'guests': return (!$isGuestEmployee && !$isOwnerEmployee); + case 'internal': return $isOwnerEmployee; + default: return false; + } +} diff --git a/api/grants/accept.php b/api/grants/accept.php new file mode 100644 index 0000000..c160915 --- /dev/null +++ b/api/grants/accept.php @@ -0,0 +1,78 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'GrantID or InviteToken is required.']); +} + +if ($userId <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'not_authenticated']); +} + +// Load grant by ID or token +if ($grantID > 0) { + $qGrant = queryOne( + "SELECT g.*, b.UserID AS GuestOwnerUserID + FROM ServicePointGrants g + JOIN Businesses b ON b.ID = g.GuestBusinessID + WHERE g.ID = ? + LIMIT 1", + [$grantID] + ); +} else { + $qGrant = queryOne( + "SELECT g.*, b.UserID AS GuestOwnerUserID + FROM ServicePointGrants g + JOIN Businesses b ON b.ID = g.GuestBusinessID + WHERE g.InviteToken = ? + LIMIT 1", + [$inviteToken] + ); +} + +if (!$qGrant) { + apiAbort(['OK' => false, 'ERROR' => 'not_found', 'MESSAGE' => 'Grant not found.']); +} + +if ((int) $qGrant['GuestOwnerUserID'] !== $userId) { + apiAbort(['OK' => false, 'ERROR' => 'not_guest_owner', 'MESSAGE' => 'Only the guest business owner can accept this invite.']); +} + +$statusID = (int) $qGrant['StatusID']; +if ($statusID !== 0) { + $statusLabels = [1 => 'already active', 2 => 'declined', 3 => 'revoked']; + $label = $statusLabels[$statusID] ?? 'not pending'; + apiAbort(['OK' => false, 'ERROR' => 'bad_state', 'MESSAGE' => "This grant is $label and cannot be accepted."]); +} + +// Accept: activate grant +queryTimed( + "UPDATE ServicePointGrants + SET StatusID = 1, AcceptedOn = NOW(), AcceptedByUserID = ?, InviteToken = NULL + WHERE ID = ?", + [$userId, $qGrant['ID']] +); + +recordGrantHistory( + (int) $qGrant['ID'], + 'accepted', + $userId, + (int) $qGrant['GuestBusinessID'], + ['StatusID' => 0], + ['StatusID' => 1] +); + +jsonResponse([ + 'OK' => true, + 'GrantID' => (int) $qGrant['ID'], + 'MESSAGE' => 'Grant accepted. Service point access is now active.', +]); diff --git a/api/grants/create.php b/api/grants/create.php new file mode 100644 index 0000000..d8c788f --- /dev/null +++ b/api/grants/create.php @@ -0,0 +1,124 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'OwnerBusinessID, GuestBusinessID, and ServicePointID are required.']); +} + +if ($ownerBusinessID === $guestBusinessID) { + apiAbort(['OK' => false, 'ERROR' => 'self_grant', 'MESSAGE' => 'Cannot grant access to your own business.']); +} + +if ($userId <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'not_authenticated', 'MESSAGE' => 'Authentication required.']); +} + +// Validate caller is the owner of OwnerBusinessID +$qOwner = queryOne("SELECT UserID FROM Businesses WHERE ID = ? LIMIT 1", [$ownerBusinessID]); +if (!$qOwner || (int) $qOwner['UserID'] !== $userId) { + apiAbort(['OK' => false, 'ERROR' => 'not_owner', 'MESSAGE' => 'You are not the owner of this business.']); +} + +// Validate ServicePoint belongs to OwnerBusinessID +$qSP = queryOne( + "SELECT ID FROM ServicePoints WHERE ID = ? AND BusinessID = ? LIMIT 1", + [$servicePointID, $ownerBusinessID] +); +if (!$qSP) { + apiAbort(['OK' => false, 'ERROR' => 'sp_not_owned', 'MESSAGE' => 'Service point does not belong to your business.']); +} + +// Validate GuestBusiness exists +$qGuest = queryOne("SELECT ID FROM Businesses WHERE ID = ? LIMIT 1", [$guestBusinessID]); +if (!$qGuest) { + apiAbort(['OK' => false, 'ERROR' => 'guest_not_found', 'MESSAGE' => 'Guest business not found.']); +} + +// Check no active or pending grant exists for this combo +$qExisting = queryOne( + "SELECT ID FROM ServicePointGrants + WHERE OwnerBusinessID = ? AND GuestBusinessID = ? AND ServicePointID = ? AND StatusID IN (0, 1) + LIMIT 1", + [$ownerBusinessID, $guestBusinessID, $servicePointID] +); +if ($qExisting) { + apiAbort(['OK' => false, 'ERROR' => 'grant_exists', 'MESSAGE' => 'An active or pending grant already exists for this service point and guest business.']); +} + +// Validate enum values +$validEconomics = ['none', 'flat_fee', 'percent_of_orders']; +$validEligibility = ['public', 'employees', 'guests', 'internal']; +$validTimePolicy = ['always', 'schedule', 'date_range', 'event']; + +if (!in_array($economicsType, $validEconomics)) $economicsType = 'none'; +if (!in_array($eligibilityScope, $validEligibility)) $eligibilityScope = 'public'; +if (!in_array($timePolicyType, $validTimePolicy)) $timePolicyType = 'always'; + +// Generate UUID and InviteToken +$newUUID = generateUUID(); +$inviteToken = generateSecureToken(); + +// Serialize TimePolicyData +$timePolicyJson = null; +if (is_array($timePolicyData) && !empty($timePolicyData)) { + $timePolicyJson = json_encode($timePolicyData); +} elseif (is_string($timePolicyData) && trim($timePolicyData) !== '') { + $timePolicyJson = $timePolicyData; +} + +// Insert grant +queryTimed( + "INSERT INTO ServicePointGrants + (UUID, OwnerBusinessID, GuestBusinessID, ServicePointID, StatusID, + EconomicsType, EconomicsValue, EligibilityScope, TimePolicyType, TimePolicyData, + InviteToken, CreatedByUserID) + VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?)", + [ + $newUUID, $ownerBusinessID, $guestBusinessID, $servicePointID, + $economicsType, $economicsValue, $eligibilityScope, $timePolicyType, $timePolicyJson, + $inviteToken, $userId, + ] +); + +$grantID = (int) lastInsertId(); + +recordGrantHistory( + $grantID, + 'created', + $userId, + $ownerBusinessID, + [], + [ + 'OwnerBusinessID' => $ownerBusinessID, + 'GuestBusinessID' => $guestBusinessID, + 'ServicePointID' => $servicePointID, + 'EconomicsType' => $economicsType, + 'EconomicsValue' => $economicsValue, + 'EligibilityScope' => $eligibilityScope, + 'TimePolicyType' => $timePolicyType, + ] +); + +jsonResponse([ + 'OK' => true, + 'GrantID' => $grantID, + 'UUID' => $newUUID, + 'InviteToken' => $inviteToken, + 'StatusID' => 0, + 'MESSAGE' => 'Grant created. Awaiting guest acceptance.', +]); diff --git a/api/grants/decline.php b/api/grants/decline.php new file mode 100644 index 0000000..aeffbd7 --- /dev/null +++ b/api/grants/decline.php @@ -0,0 +1,56 @@ + false, 'ERROR' => 'missing_grantid', 'MESSAGE' => 'GrantID is required.']); +} + +if ($userId <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'not_authenticated']); +} + +$qGrant = queryOne( + "SELECT g.*, b.UserID AS GuestOwnerUserID + FROM ServicePointGrants g + JOIN Businesses b ON b.ID = g.GuestBusinessID + WHERE g.ID = ? + LIMIT 1", + [$grantID] +); + +if (!$qGrant) { + apiAbort(['OK' => false, 'ERROR' => 'not_found', 'MESSAGE' => 'Grant not found.']); +} + +if ((int) $qGrant['GuestOwnerUserID'] !== $userId) { + apiAbort(['OK' => false, 'ERROR' => 'not_guest_owner', 'MESSAGE' => 'Only the guest business owner can decline this invite.']); +} + +if ((int) $qGrant['StatusID'] !== 0) { + apiAbort(['OK' => false, 'ERROR' => 'bad_state', 'MESSAGE' => 'Only pending grants can be declined.']); +} + +queryTimed("UPDATE ServicePointGrants SET StatusID = 2 WHERE ID = ?", [$grantID]); + +recordGrantHistory( + $grantID, + 'declined', + $userId, + (int) $qGrant['GuestBusinessID'], + ['StatusID' => 0], + ['StatusID' => 2] +); + +jsonResponse([ + 'OK' => true, + 'GrantID' => $grantID, + 'MESSAGE' => 'Grant declined.', +]); diff --git a/api/grants/get.php b/api/grants/get.php new file mode 100644 index 0000000..8f648b6 --- /dev/null +++ b/api/grants/get.php @@ -0,0 +1,96 @@ + false, 'ERROR' => 'missing_grantid', 'MESSAGE' => 'GrantID is required.']); +} + +if ($userId <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'not_authenticated']); +} + +$qGrant = queryOne( + "SELECT + g.*, + ob.Name AS OwnerBusinessName, + gb.Name AS GuestBusinessName, + sp.Name AS ServicePointName, + sp.TypeID AS ServicePointTypeID, + cu.FirstName AS CreatedByFirstName, + cu.LastName AS CreatedByLastName, + au.FirstName AS AcceptedByFirstName, + au.LastName AS AcceptedByLastName + FROM ServicePointGrants g + JOIN Businesses ob ON ob.ID = g.OwnerBusinessID + JOIN Businesses gb ON gb.ID = g.GuestBusinessID + JOIN ServicePoints sp ON sp.ID = g.ServicePointID + LEFT JOIN Users cu ON cu.ID = g.CreatedByUserID + LEFT JOIN Users au ON au.ID = g.AcceptedByUserID + WHERE g.ID = ? + LIMIT 1", + [$grantID] +); + +if (!$qGrant) { + apiAbort(['OK' => false, 'ERROR' => 'not_found', 'MESSAGE' => 'Grant not found.']); +} + +// Load history +$historyRows = queryTimed( + "SELECT h.*, u.FirstName, u.LastName + FROM ServicePointGrantHistory h + LEFT JOIN Users u ON u.ID = h.ActorUserID + WHERE h.GrantID = ? + ORDER BY h.CreatedOn DESC", + [$grantID] +); + +$history = []; +foreach ($historyRows as $row) { + $history[] = [ + 'ID' => (int) $row['ID'], + 'Action' => $row['Action'], + 'ActorUserID' => (int) $row['ActorUserID'], + 'ActorName' => trim(($row['FirstName'] ?? '') . ' ' . ($row['LastName'] ?? '')), + 'ActorBusinessID' => (int) $row['ActorBusinessID'], + 'PreviousData' => $row['PreviousData'] ?? '', + 'NewData' => $row['NewData'] ?? '', + 'CreatedOn' => $row['CreatedOn'], + ]; +} + +$grant = [ + 'GrantID' => (int) $qGrant['ID'], + 'UUID' => $qGrant['UUID'], + 'OwnerBusinessID' => (int) $qGrant['OwnerBusinessID'], + 'GuestBusinessID' => (int) $qGrant['GuestBusinessID'], + 'ServicePointID' => (int) $qGrant['ServicePointID'], + 'StatusID' => (int) $qGrant['StatusID'], + 'EconomicsType' => $qGrant['EconomicsType'], + 'EconomicsValue' => (float) $qGrant['EconomicsValue'], + 'EligibilityScope' => $qGrant['EligibilityScope'], + 'TimePolicyType' => $qGrant['TimePolicyType'], + 'TimePolicyData' => $qGrant['TimePolicyData'] ?? '', + 'CreatedOn' => $qGrant['CreatedOn'], + 'AcceptedOn' => $qGrant['AcceptedOn'] ?? '', + 'RevokedOn' => $qGrant['RevokedOn'] ?? '', + 'LastEditedOn' => $qGrant['LastEditedOn'], + 'OwnerBusinessName' => $qGrant['OwnerBusinessName'], + 'GuestBusinessName' => $qGrant['GuestBusinessName'], + 'ServicePointName' => $qGrant['ServicePointName'], + 'ServicePointTypeID' => (int) $qGrant['ServicePointTypeID'], + 'CreatedByName' => trim(($qGrant['CreatedByFirstName'] ?? '') . ' ' . ($qGrant['CreatedByLastName'] ?? '')), + 'AcceptedByName' => trim(($qGrant['AcceptedByFirstName'] ?? '') . ' ' . ($qGrant['AcceptedByLastName'] ?? '')), +]; + +jsonResponse([ + 'OK' => true, + 'Grant' => $grant, + 'History' => $history, +]); diff --git a/api/grants/list.php b/api/grants/list.php new file mode 100644 index 0000000..32b137f --- /dev/null +++ b/api/grants/list.php @@ -0,0 +1,91 @@ + false, 'ERROR' => 'missing_businessid', 'MESSAGE' => 'BusinessID is required.']); +} + +if ($userId <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'not_authenticated']); +} + +// Build query based on role +$whereClause = ($role === 'guest') ? 'g.GuestBusinessID = ?' : 'g.OwnerBusinessID = ?'; +$params = [$bizID]; + +$statusClause = ''; +if ($statusFilter >= 0) { + $statusClause = ' AND g.StatusID = ?'; + $params[] = $statusFilter; +} + +$rows = queryTimed( + "SELECT + g.ID AS GrantID, + g.UUID, + g.OwnerBusinessID, + g.GuestBusinessID, + g.ServicePointID, + g.StatusID, + g.EconomicsType, + g.EconomicsValue, + g.EligibilityScope, + g.TimePolicyType, + g.TimePolicyData, + g.CreatedOn, + g.AcceptedOn, + g.RevokedOn, + ob.Name AS OwnerBusinessName, + gb.Name AS GuestBusinessName, + sp.Name AS ServicePointName, + sp.TypeID AS ServicePointTypeID + FROM ServicePointGrants g + JOIN Businesses ob ON ob.ID = g.OwnerBusinessID + JOIN Businesses gb ON gb.ID = g.GuestBusinessID + JOIN ServicePoints sp ON sp.ID = g.ServicePointID + WHERE $whereClause$statusClause + ORDER BY g.CreatedOn DESC + LIMIT 200", + $params +); + +$grants = []; +foreach ($rows as $row) { + $grants[] = [ + 'GrantID' => (int) $row['GrantID'], + 'UUID' => $row['UUID'], + 'OwnerBusinessID' => (int) $row['OwnerBusinessID'], + 'GuestBusinessID' => (int) $row['GuestBusinessID'], + 'ServicePointID' => (int) $row['ServicePointID'], + 'StatusID' => (int) $row['StatusID'], + 'EconomicsType' => $row['EconomicsType'], + 'EconomicsValue' => (float) $row['EconomicsValue'], + 'EligibilityScope' => $row['EligibilityScope'], + 'TimePolicyType' => $row['TimePolicyType'], + 'TimePolicyData' => $row['TimePolicyData'] ?? '', + 'CreatedOn' => $row['CreatedOn'], + 'AcceptedOn' => $row['AcceptedOn'] ?? '', + 'RevokedOn' => $row['RevokedOn'] ?? '', + 'OwnerBusinessName' => $row['OwnerBusinessName'], + 'GuestBusinessName' => $row['GuestBusinessName'], + 'ServicePointName' => $row['ServicePointName'], + 'ServicePointTypeID' => (int) $row['ServicePointTypeID'], + ]; +} + +jsonResponse([ + 'OK' => true, + 'Role' => $role, + 'BusinessID' => $bizID, + 'Count' => count($grants), + 'Grants' => $grants, +]); diff --git a/api/grants/revoke.php b/api/grants/revoke.php new file mode 100644 index 0000000..d02fe53 --- /dev/null +++ b/api/grants/revoke.php @@ -0,0 +1,62 @@ + false, 'ERROR' => 'missing_grantid', 'MESSAGE' => 'GrantID is required.']); +} + +if ($userId <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'not_authenticated']); +} + +$qGrant = queryOne( + "SELECT g.ID, g.OwnerBusinessID, g.GuestBusinessID, g.StatusID, b.UserID AS OwnerUserID + FROM ServicePointGrants g + JOIN Businesses b ON b.ID = g.OwnerBusinessID + WHERE g.ID = ? + LIMIT 1", + [$grantID] +); + +if (!$qGrant) { + apiAbort(['OK' => false, 'ERROR' => 'not_found', 'MESSAGE' => 'Grant not found.']); +} + +if ((int) $qGrant['OwnerUserID'] !== $userId) { + apiAbort(['OK' => false, 'ERROR' => 'not_owner', 'MESSAGE' => 'Only the owner business can revoke a grant.']); +} + +$statusID = (int) $qGrant['StatusID']; + +if ($statusID === 3) { + apiAbort(['OK' => true, 'MESSAGE' => 'Grant is already revoked.', 'GrantID' => $grantID]); +} + +if ($statusID !== 0 && $statusID !== 1) { + apiAbort(['OK' => false, 'ERROR' => 'bad_state', 'MESSAGE' => 'Only pending or active grants can be revoked.']); +} + +queryTimed("UPDATE ServicePointGrants SET StatusID = 3, RevokedOn = NOW() WHERE ID = ?", [$grantID]); + +recordGrantHistory( + $grantID, + 'revoked', + $userId, + (int) $qGrant['OwnerBusinessID'], + ['StatusID' => $statusID], + ['StatusID' => 3] +); + +jsonResponse([ + 'OK' => true, + 'GrantID' => $grantID, + 'MESSAGE' => 'Grant revoked. All access stopped immediately.', +]); diff --git a/api/grants/searchBusiness.php b/api/grants/searchBusiness.php new file mode 100644 index 0000000..de81001 --- /dev/null +++ b/api/grants/searchBusiness.php @@ -0,0 +1,45 @@ + false, 'ERROR' => 'query_too_short', 'MESSAGE' => 'Search query must be at least 2 characters.']); +} + +$params = []; +$sql = "SELECT ID, Name FROM Businesses WHERE 1=1"; + +if (is_numeric($query)) { + $sql .= " AND ID = ?"; + $params[] = (int) $query; +} else { + $sql .= " AND Name LIKE ?"; + $params[] = '%' . $query . '%'; +} + +if ($excludeBusinessID > 0) { + $sql .= " AND ID != ?"; + $params[] = $excludeBusinessID; +} + +$sql .= " ORDER BY Name LIMIT 20"; + +$rows = queryTimed($sql, $params); + +$businesses = []; +foreach ($rows as $row) { + $businesses[] = [ + 'BusinessID' => (int) $row['ID'], + 'Name' => $row['Name'], + ]; +} + +jsonResponse([ + 'OK' => true, + 'Count' => count($businesses), + 'Businesses' => $businesses, +]); diff --git a/api/grants/update.php b/api/grants/update.php new file mode 100644 index 0000000..40860a2 --- /dev/null +++ b/api/grants/update.php @@ -0,0 +1,140 @@ + false, 'ERROR' => 'missing_grantid', 'MESSAGE' => 'GrantID is required.']); +} + +if ($userId <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'not_authenticated']); +} + +$qGrant = queryOne( + "SELECT g.*, b.UserID AS OwnerUserID + FROM ServicePointGrants g + JOIN Businesses b ON b.ID = g.OwnerBusinessID + WHERE g.ID = ? + LIMIT 1", + [$grantID] +); + +if (!$qGrant) { + apiAbort(['OK' => false, 'ERROR' => 'not_found', 'MESSAGE' => 'Grant not found.']); +} + +if ((int) $qGrant['OwnerUserID'] !== $userId) { + apiAbort(['OK' => false, 'ERROR' => 'not_owner', 'MESSAGE' => 'Only the owner business can update grant terms.']); +} + +$statusID = (int) $qGrant['StatusID']; +if ($statusID !== 0 && $statusID !== 1) { + apiAbort(['OK' => false, 'ERROR' => 'bad_state', 'MESSAGE' => 'Only pending or active grants can be updated.']); +} + +$setClauses = []; +$setParams = []; +$previousData = []; +$newData = []; + +$validEconomics = ['none', 'flat_fee', 'percent_of_orders']; +$validEligibility = ['public', 'employees', 'guests', 'internal']; +$validTimePolicy = ['always', 'schedule', 'date_range', 'event']; + +// Economics +if (isset($data['EconomicsType']) || isset($data['EconomicsValue'])) { + $eType = trim($data['EconomicsType'] ?? $qGrant['EconomicsType']); + $eValue = (float) ($data['EconomicsValue'] ?? $qGrant['EconomicsValue']); + if (!in_array($eType, $validEconomics)) $eType = $qGrant['EconomicsType']; + + if ($eType !== $qGrant['EconomicsType'] || $eValue != (float) $qGrant['EconomicsValue']) { + $previousData['EconomicsType'] = $qGrant['EconomicsType']; + $previousData['EconomicsValue'] = (float) $qGrant['EconomicsValue']; + $newData['EconomicsType'] = $eType; + $newData['EconomicsValue'] = $eValue; + $setClauses[] = 'EconomicsType = ?'; + $setParams[] = $eType; + $setClauses[] = 'EconomicsValue = ?'; + $setParams[] = $eValue; + } +} + +// Eligibility +if (isset($data['EligibilityScope'])) { + $eScope = trim($data['EligibilityScope']); + if (!in_array($eScope, $validEligibility)) $eScope = $qGrant['EligibilityScope']; + if ($eScope !== $qGrant['EligibilityScope']) { + $previousData['EligibilityScope'] = $qGrant['EligibilityScope']; + $newData['EligibilityScope'] = $eScope; + $setClauses[] = 'EligibilityScope = ?'; + $setParams[] = $eScope; + } +} + +// Time policy +if (isset($data['TimePolicyType']) || isset($data['TimePolicyData'])) { + $tType = trim($data['TimePolicyType'] ?? $qGrant['TimePolicyType']); + if (!in_array($tType, $validTimePolicy)) $tType = $qGrant['TimePolicyType']; + $tData = $data['TimePolicyData'] ?? $qGrant['TimePolicyData']; + + $changed = ($tType !== $qGrant['TimePolicyType']); + if (!$changed && is_array($tData)) { + $changed = (json_encode($tData) !== ($qGrant['TimePolicyData'] ?? '')); + } + + if ($changed) { + $previousData['TimePolicyType'] = $qGrant['TimePolicyType']; + $previousData['TimePolicyData'] = $qGrant['TimePolicyData'] ?? ''; + $newData['TimePolicyType'] = $tType; + $newData['TimePolicyData'] = $tData; + $setClauses[] = 'TimePolicyType = ?'; + $setParams[] = $tType; + + $tDataJson = null; + if (is_array($tData) && !empty($tData)) { + $tDataJson = json_encode($tData); + } elseif (is_string($tData) && trim($tData) !== '') { + $tDataJson = $tData; + } + $setClauses[] = 'TimePolicyData = ?'; + $setParams[] = $tDataJson; + } +} + +if (empty($setClauses)) { + apiAbort(['OK' => true, 'MESSAGE' => 'No changes detected.', 'GrantID' => $grantID]); +} + +$setParams[] = $grantID; +queryTimed( + "UPDATE ServicePointGrants SET " . implode(', ', $setClauses) . " WHERE ID = ?", + $setParams +); + +// Determine action name for history +$action = 'updated'; +if (isset($newData['EconomicsType']) || isset($newData['EconomicsValue'])) $action = 'updated_economics'; +if (isset($newData['EligibilityScope'])) $action = 'updated_eligibility'; +if (isset($newData['TimePolicyType'])) $action = 'updated_time_policy'; + +recordGrantHistory( + $grantID, + $action, + $userId, + (int) $qGrant['OwnerBusinessID'], + $previousData, + $newData +); + +jsonResponse([ + 'OK' => true, + 'GrantID' => $grantID, + 'MESSAGE' => 'Grant updated.', +]); diff --git a/api/helpers.php b/api/helpers.php new file mode 100644 index 0000000..0c210cc --- /dev/null +++ b/api/helpers.php @@ -0,0 +1,484 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ] + ); + return $pdo; +} + +// ============================================ +// PERFORMANCE TRACKING +// ============================================ + +$_perfStart = hrtime(true); +$_perfQueryCount = 0; +$_perfQueryTimeMs = 0; + +/** + * Execute a prepared query with timing. + * Returns an array of associative rows for SELECT, or the PDOStatement for INSERT/UPDATE/DELETE. + */ +function queryTimed(string $sql, array $params = []): array|PDOStatement { + global $_perfQueryCount, $_perfQueryTimeMs; + + $db = getDb(); + $start = hrtime(true); + $stmt = $db->prepare($sql); + $stmt->execute($params); + $elapsed = (hrtime(true) - $start) / 1_000_000; // nanoseconds to ms + + $_perfQueryCount++; + $_perfQueryTimeMs += $elapsed; + + // If it's a SELECT, return rows. Otherwise return the statement. + if (stripos(trim($sql), 'SELECT') === 0) { + return $stmt->fetchAll(); + } + return $stmt; +} + +/** + * Execute a SELECT query and return a single row, or null if not found. + */ +function queryOne(string $sql, array $params = []): ?array { + $rows = queryTimed($sql, $params); + return $rows[0] ?? null; +} + +/** + * Get the last inserted auto-increment ID. + */ +function lastInsertId(): string { + return getDb()->lastInsertId(); +} + +// ============================================ +// REQUEST HELPERS +// ============================================ + +/** + * Read and parse the JSON request body. + */ +function readJsonBody(): array { + $raw = file_get_contents('php://input'); + if (empty($raw)) return []; + $data = json_decode($raw, true); + return is_array($data) ? $data : []; +} + +/** + * Get a request header value (case-insensitive). + */ +function headerValue(string $name): string { + // PHP converts headers to HTTP_UPPER_SNAKE_CASE in $_SERVER + $key = 'HTTP_' . strtoupper(str_replace('-', '_', $name)); + return trim($_SERVER[$key] ?? ''); +} + +// ============================================ +// RESPONSE HELPERS +// ============================================ + +/** + * Send a JSON response and exit. + */ +function jsonResponse(array $payload, int $statusCode = 200): never { + http_response_code($statusCode); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($payload, JSON_UNESCAPED_UNICODE); + exit; +} + +/** + * Send an error response and exit. + */ +function apiAbort(array $payload): never { + jsonResponse($payload); +} + +// ============================================ +// DATE HELPERS +// ============================================ + +/** + * Format a datetime string as ISO 8601 UTC. + * Input is already UTC from the database. + */ +function toISO8601(?string $d): string { + if (empty($d)) return ''; + $dt = new DateTime($d, new DateTimeZone('UTC')); + return $dt->format('Y-m-d\TH:i:s\Z'); +} + +/** + * Get current time in a specific timezone (for business hours checking). + */ +function getTimeInZone(string $tz = 'America/Los_Angeles'): string { + try { + $now = new DateTime('now', new DateTimeZone($tz)); + return $now->format('H:i:s'); + } catch (Exception) { + return (new DateTime('now', new DateTimeZone('UTC')))->format('H:i:s'); + } +} + +/** + * Get current day of week in a specific timezone (1=Sunday, 7=Saturday). + */ +function getDayInZone(string $tz = 'America/Los_Angeles'): int { + try { + $now = new DateTime('now', new DateTimeZone($tz)); + return (int) $now->format('w') + 1; // 'w' is 0=Sun, we want 1=Sun + } catch (Exception) { + return (int) (new DateTime())->format('w') + 1; + } +} + +// ============================================ +// ENVIRONMENT +// ============================================ + +function isDev(): bool { + return gethostname() !== 'biz'; +} + +function baseUrl(): string { + return isDev() ? 'https://dev.payfrit.com' : 'https://biz.payfrit.com'; +} + +// ============================================ +// PHONE HELPERS +// ============================================ + +/** + * Strip a US phone number to 10 digits (remove country code, formatting). + */ +function normalizePhone(string $p): string { + $digits = preg_replace('/[^0-9]/', '', trim($p)); + if (strlen($digits) === 11 && $digits[0] === '1') { + $digits = substr($digits, 1); + } + return $digits; +} + +/** + * Check if a string looks like a phone number (10-11 digits). + */ +function isPhoneNumber(string $input): bool { + $digits = preg_replace('/[^0-9]/', '', $input); + return strlen($digits) >= 10 && strlen($digits) <= 11; +} + +// ============================================ +// SECURITY +// ============================================ + +/** + * Generate a secure random token (256-bit, SHA-256 hashed). + */ +function generateSecureToken(): string { + return hash('sha256', random_bytes(32)); +} + +/** + * Generate a v4 UUID. + */ +function generateUUID(): string { + $bytes = random_bytes(16); + // Set version 4 bits + $bytes[6] = chr(ord($bytes[6]) & 0x0f | 0x40); + // Set variant bits + $bytes[8] = chr(ord($bytes[8]) & 0x3f | 0x80); + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4)); +} + +// ============================================ +// AUTH MIDDLEWARE +// ============================================ + +// Public routes that don't require authentication +const PUBLIC_ROUTES = [ + // auth + '/api/auth/login.php', + '/api/auth/logout.php', + '/api/auth/sendOTP.php', + '/api/auth/verifyOTP.php', + '/api/auth/loginOTP.php', + '/api/auth/verifyLoginOTP.php', + '/api/auth/sendLoginOTP.php', + '/api/auth/verifyEmailOTP.php', + '/api/auth/completeProfile.php', + '/api/auth/validateToken.php', + '/api/auth/profile.php', + '/api/auth/avatar.php', + // businesses + '/api/businesses/list.php', + '/api/businesses/get.php', + '/api/businesses/getChildren.php', + '/api/businesses/update.php', + '/api/businesses/updateHours.php', + '/api/businesses/updateTabs.php', + '/api/businesses/setHiring.php', + '/api/businesses/saveBrandColor.php', + '/api/businesses/saveOrderTypes.php', + // servicepoints + '/api/servicepoints/list.php', + '/api/servicepoints/get.php', + '/api/servicepoints/save.php', + '/api/servicepoints/delete.php', + '/api/servicepoints/reassign_all.php', + // beacons + '/api/beacons/list.php', + '/api/beacons/get.php', + '/api/beacons/save.php', + '/api/beacons/delete.php', + '/api/beacons/list_all.php', + '/api/beacons/getBusinessFromBeacon.php', + '/api/beacons/reassign_all.php', + '/api/beacons/lookup.php', + // beacon-sharding + '/api/beacon-sharding/allocate_business_namespace.php', + '/api/beacon-sharding/allocate_servicepoint_minor.php', + '/api/beacon-sharding/get_beacon_config.php', + '/api/beacon-sharding/get_shard_pool.php', + '/api/beacon-sharding/register_beacon_hardware.php', + '/api/beacon-sharding/resolve_business.php', + '/api/beacon-sharding/resolve_servicepoint.php', + '/api/beacon-sharding/verify_beacon_broadcast.php', + // menu + '/api/menu/items.php', + '/api/menu/getForBuilder.php', + '/api/menu/saveFromBuilder.php', + '/api/menu/updateStations.php', + '/api/menu/menus.php', + '/api/menu/uploadHeader.php', + '/api/menu/uploadItemPhoto.php', + '/api/menu/listCategories.php', + '/api/menu/saveCategory.php', + '/api/menu/clearAllData.php', + '/api/menu/clearBusinessData.php', + '/api/menu/clearOrders.php', + '/api/menu/debug.php', + // orders + '/api/orders/getOrCreateCart.php', + '/api/orders/getCart.php', + '/api/orders/getActiveCart.php', + '/api/orders/setLineItem.php', + '/api/orders/setOrderType.php', + '/api/orders/submit.php', + '/api/orders/submitCash.php', + '/api/orders/abandonOrder.php', + '/api/orders/listForKDS.php', + '/api/orders/updateStatus.php', + '/api/orders/markStationDone.php', + '/api/orders/checkStatusUpdate.php', + '/api/orders/getDetail.php', + '/api/orders/history.php', + '/api/orders/getPendingForUser.php', + // addresses + '/api/addresses/states.php', + '/api/addresses/list.php', + '/api/addresses/add.php', + '/api/addresses/delete.php', + '/api/addresses/setDefault.php', + '/api/addresses/types.php', + // assignments + '/api/assignments/list.php', + '/api/assignments/save.php', + '/api/assignments/delete.php', + // tasks + '/api/tasks/listPending.php', + '/api/tasks/accept.php', + '/api/tasks/listMine.php', + '/api/tasks/complete.php', + '/api/tasks/completeChat.php', + '/api/tasks/getDetails.php', + '/api/tasks/create.php', + '/api/tasks/createChat.php', + '/api/tasks/callServer.php', + '/api/tasks/expireStaleChats.php', + '/api/tasks/listCategories.php', + '/api/tasks/saveCategory.php', + '/api/tasks/deleteCategory.php', + '/api/tasks/seedCategories.php', + '/api/tasks/listAllTypes.php', + '/api/tasks/listTypes.php', + '/api/tasks/saveType.php', + '/api/tasks/deleteType.php', + '/api/tasks/reorderTypes.php', + // chat + '/api/chat/getMessages.php', + '/api/chat/sendMessage.php', + '/api/chat/markRead.php', + '/api/chat/getActiveChat.php', + '/api/chat/closeChat.php', + // workers + '/api/workers/myBusinesses.php', + '/api/workers/tierStatus.php', + '/api/workers/createAccount.php', + '/api/workers/onboardingLink.php', + '/api/workers/earlyUnlock.php', + '/api/workers/ledger.php', + // portal + '/api/portal/stats.php', + '/api/portal/myBusinesses.php', + '/api/portal/team.php', + '/api/portal/searchUser.php', + '/api/portal/addTeamMember.php', + '/api/portal/reassign_employees.php', + // users + '/api/users/search.php', + // stations + '/api/stations/list.php', + '/api/stations/save.php', + '/api/stations/delete.php', + // ratings + '/api/ratings/setup.php', + '/api/ratings/submit.php', + '/api/ratings/createAdminRating.php', + '/api/ratings/listForAdmin.php', + // app + '/api/app/about.php', + // grants + '/api/grants/create.php', + '/api/grants/list.php', + '/api/grants/get.php', + '/api/grants/update.php', + '/api/grants/revoke.php', + '/api/grants/accept.php', + '/api/grants/decline.php', + '/api/grants/searchBusiness.php', + // stripe + '/api/stripe/onboard.php', + '/api/stripe/status.php', + '/api/stripe/createPaymentIntent.php', + '/api/stripe/getPaymentConfig.php', + '/api/stripe/webhook.php', + // setup + '/api/setup/importBusiness.php', + '/api/setup/analyzeMenu.php', + '/api/setup/analyzeMenuImages.php', + '/api/setup/analyzeMenuUrl.php', + '/api/setup/uploadSavedPage.php', + '/api/setup/saveWizard.php', + '/api/setup/downloadImages.php', + '/api/setup/checkDuplicate.php', + '/api/setup/lookupTaxRate.php', + '/api/setup/reimportBigDeans.php', + '/api/setup/testUpload.php', + // presence + '/api/presence/heartbeat.php', + // tabs + '/api/tabs/open.php', + '/api/tabs/close.php', + '/api/tabs/get.php', + '/api/tabs/getActive.php', + '/api/tabs/getPresence.php', + '/api/tabs/addMember.php', + '/api/tabs/removeMember.php', + '/api/tabs/addOrder.php', + '/api/tabs/approveOrder.php', + '/api/tabs/rejectOrder.php', + '/api/tabs/pendingOrders.php', + '/api/tabs/increaseAuth.php', + '/api/tabs/cancel.php', +]; + +/** + * Run auth check. Call at the top of every endpoint after including helpers. + * Sets global $userId and $businessId. + * Returns early for public routes. + */ +$userId = 0; +$businessId = 0; + +function runAuth(): void { + global $userId, $businessId; + + $path = strtolower($_SERVER['SCRIPT_NAME'] ?? ''); + + // Check token + $token = headerValue('X-User-Token'); + if (!empty($token)) { + $row = queryOne( + "SELECT UserID FROM UserTokens WHERE Token = ? LIMIT 1", + [$token] + ); + if ($row) { + $userId = (int) $row['UserID']; + } + } + + // Business header + $bizHeader = headerValue('X-Business-ID'); + if (!empty($bizHeader) && is_numeric($bizHeader)) { + $businessId = (int) $bizHeader; + } + + // Check if public route + $isPublic = false; + foreach (PUBLIC_ROUTES as $route) { + if (str_contains($path, $route)) { + $isPublic = true; + break; + } + } + // Also allow /api/admin/ paths + if (str_contains($path, '/api/admin/')) { + $isPublic = true; + } + + if (!$isPublic) { + if ($userId <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'not_logged_in']); + } + if ($businessId <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'no_business_selected']); + } + } +} diff --git a/api/menu/clearAllData.php b/api/menu/clearAllData.php new file mode 100644 index 0000000..a35efd9 --- /dev/null +++ b/api/menu/clearAllData.php @@ -0,0 +1,40 @@ + false, 'ERROR' => "Must pass confirm: 'NUKE_EVERYTHING' to proceed"]); +} + +try { + // Get counts before deletion + $itemCount = queryOne("SELECT COUNT(*) as cnt FROM Items"); + $catCount = queryOne("SELECT COUNT(*) as cnt FROM Categories"); + $linkCount = queryOne("SELECT COUNT(*) as cnt FROM lt_ItemID_TemplateItemID"); + + // Delete in correct order (foreign key constraints) + queryTimed("DELETE FROM lt_ItemID_TemplateItemID"); + queryTimed("DELETE FROM Items"); + queryTimed("DELETE FROM Categories"); + + jsonResponse([ + 'OK' => true, + 'deleted' => [ + 'items' => (int) $itemCount['cnt'], + 'categories' => (int) $catCount['cnt'], + 'templateLinks' => (int) $linkCount['cnt'], + ], + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => $e->getMessage(), 'DETAIL' => '']); +} diff --git a/api/menu/clearBusinessData.php b/api/menu/clearBusinessData.php new file mode 100644 index 0000000..7d826e9 --- /dev/null +++ b/api/menu/clearBusinessData.php @@ -0,0 +1,61 @@ + false, 'ERROR' => 'BusinessID is required']); +} + +if ($confirm !== 'DELETE_ALL_DATA') { + jsonResponse(['OK' => false, 'ERROR' => "Must pass confirm: 'DELETE_ALL_DATA' to proceed"]); +} + +try { + // Get counts before deletion + $itemCount = queryOne("SELECT COUNT(*) as cnt FROM Items WHERE BusinessID = ?", [$businessID]); + $catCount = queryOne("SELECT COUNT(*) as cnt FROM Categories WHERE BusinessID = ?", [$businessID]); + + // Get item IDs for this business to delete template links + $itemRows = queryTimed("SELECT ID FROM Items WHERE BusinessID = ?", [$businessID]); + $itemIds = array_column($itemRows, 'ID'); + + $deletedLinks = 0; + if (count($itemIds) > 0) { + $placeholders = implode(',', array_fill(0, count($itemIds), '?')); + $params = array_merge($itemIds, $itemIds); + queryTimed( + "DELETE FROM lt_ItemID_TemplateItemID WHERE ItemID IN ($placeholders) OR TemplateItemID IN ($placeholders)", + $params + ); + $deletedLinks = count($itemIds); + } + + // Delete all items for this business + queryTimed("DELETE FROM Items WHERE BusinessID = ?", [$businessID]); + + // Delete all categories for this business + queryTimed("DELETE FROM Categories WHERE BusinessID = ?", [$businessID]); + + jsonResponse([ + 'OK' => true, + 'deleted' => [ + 'items' => (int) $itemCount['cnt'], + 'categories' => (int) $catCount['cnt'], + 'templateLinks' => $deletedLinks, + ], + 'businessID' => $businessID, + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => $e->getMessage()]); +} diff --git a/api/menu/clearOrders.php b/api/menu/clearOrders.php new file mode 100644 index 0000000..9092da1 --- /dev/null +++ b/api/menu/clearOrders.php @@ -0,0 +1,43 @@ + false, 'ERROR' => "Must pass confirm: 'NUKE_ORDERS' to proceed"]); +} + +try { + // Get counts before deletion + $lineItemCount = queryOne("SELECT COUNT(*) as cnt FROM OrderLineItems"); + $orderCount = queryOne("SELECT COUNT(*) as cnt FROM Orders"); + $addressCount = queryOne("SELECT COUNT(*) as cnt FROM Addresses WHERE AddressTypeID != 2"); + $taskCount = queryOne("SELECT COUNT(*) as cnt FROM Tasks"); + + // Delete in correct order (foreign key constraints) + queryTimed("DELETE FROM Tasks"); + queryTimed("DELETE FROM OrderLineItems"); + queryTimed("DELETE FROM Orders"); + queryTimed("DELETE FROM Addresses WHERE AddressTypeID != 2"); + + jsonResponse([ + 'OK' => true, + 'deleted' => [ + 'tasks' => (int) $taskCount['cnt'], + 'lineItems' => (int) $lineItemCount['cnt'], + 'orders' => (int) $orderCount['cnt'], + 'addresses' => (int) $addressCount['cnt'], + ], + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => $e->getMessage(), 'DETAIL' => '']); +} diff --git a/api/menu/debug.php b/api/menu/debug.php new file mode 100644 index 0000000..6d88652 --- /dev/null +++ b/api/menu/debug.php @@ -0,0 +1,73 @@ + 0 + GROUP BY BusinessID + "); + + $response['businesses_with_items'] = []; + foreach ($bizRows as $b) { + $response['businesses_with_items'][] = [ + 'businessID' => (int) $b['BusinessID'], + 'itemCount' => (int) $b['ItemCount'], + ]; + } + + // Get categories grouped by business + $catRows = queryTimed(" + SELECT BusinessID, COUNT(*) as cnt + FROM Categories + GROUP BY BusinessID + "); + + $response['categories_by_business'] = []; + foreach ($catRows as $c) { + $response['categories_by_business'][] = [ + 'businessID' => (int) $c['BusinessID'], + 'count' => (int) $c['cnt'], + ]; + } + + // Get sample items + $sampleRows = queryTimed(" + SELECT ID, BusinessID, CategoryID, ParentItemID, Name, IsActive + FROM Items + WHERE IsActive = 1 + LIMIT 20 + "); + + $response['sample_items'] = []; + foreach ($sampleRows as $i) { + $response['sample_items'][] = [ + 'id' => (int) $i['ID'], + 'businessID' => (int) $i['BusinessID'], + 'categoryID' => (int) $i['CategoryID'], + 'parentID' => (int) $i['ParentItemID'], + 'name' => $i['Name'], + 'active' => (int) $i['IsActive'], + ]; + } + + // Get template link count + $linkCount = queryOne("SELECT COUNT(*) as cnt FROM lt_ItemID_TemplateItemID"); + $response['template_link_count'] = (int) $linkCount['cnt']; + + $response['OK'] = true; + + jsonResponse($response); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => $e->getMessage(), 'DETAIL' => '']); +} diff --git a/api/menu/getForBuilder.php b/api/menu/getForBuilder.php new file mode 100644 index 0000000..a040a39 --- /dev/null +++ b/api/menu/getForBuilder.php @@ -0,0 +1,400 @@ + 'opt_' . $opt['ItemID'], + 'dbId' => (int) $opt['ItemID'], + 'name' => $opt['Name'], + 'price' => $opt['Price'], + 'isDefault' => (int) $opt['IsDefault'] === 1, + 'sortOrder' => (int) $opt['SortOrder'], + 'requiresSelection' => (int) ($opt['RequiresSelection'] ?? 0) === 1, + 'maxSelections' => (int) ($opt['MaxSelections'] ?? 0), + 'options' => $children, + ]; + } + usort($result, fn($a, $b) => $a['sortOrder'] - $b['sortOrder']); + return $result; +} + +$data = readJsonBody(); +$businessID = (int) ($data['BusinessID'] ?? 0); + +if ($businessID === 0) { + jsonResponse(['OK' => false, 'ERROR' => 'missing_business_id', 'MESSAGE' => 'BusinessID is required']); +} + +$menuID = (int) ($data['MenuID'] ?? 0); + +try { + // Get business default menu and timezone + $defaultMenuID = 0; + $businessTimezone = 'America/Los_Angeles'; + try { + $qBiz = queryOne("SELECT DefaultMenuID, Timezone FROM Businesses WHERE ID = ?", [$businessID]); + if ($qBiz) { + if (!empty($qBiz['DefaultMenuID'])) $defaultMenuID = (int) $qBiz['DefaultMenuID']; + if (!empty($qBiz['Timezone'])) $businessTimezone = $qBiz['Timezone']; + } + } catch (Exception $e) {} + + // Get all menus for this business + $allMenus = []; + try { + $qMenus = queryTimed(" + SELECT ID, Name, Description, DaysActive, StartTime, EndTime, SortOrder + FROM Menus + WHERE BusinessID = ? AND IsActive = 1 + ORDER BY SortOrder, Name + ", [$businessID]); + + foreach ($qMenus as $m) { + $allMenus[] = [ + 'MenuID' => (int) $m['ID'], + 'MenuName' => $m['Name'], + 'MenuDescription' => $m['Description'] ?? '', + 'MenuDaysActive' => (int) $m['DaysActive'], + 'MenuStartTime' => !empty($m['StartTime']) ? (new DateTime($m['StartTime']))->format('H:i') : '', + 'MenuEndTime' => !empty($m['EndTime']) ? (new DateTime($m['EndTime']))->format('H:i') : '', + 'SortOrder' => (int) $m['SortOrder'], + ]; + } + + // Auto-select menu based on current time when no specific menu requested + if ($menuID === 0 && count($qMenus) > 1) { + $currentTime = substr(getTimeInZone($businessTimezone), 0, 5); // "HH:mm" + $currentDay = getDayInZone($businessTimezone); // 1=Sun, 2=Mon, ... 7=Sat + $dayBit = pow(2, $currentDay - 1); + $activeMenuIds = []; + + foreach ($qMenus as $m) { + if (((int) $m['DaysActive'] & $dayBit) === 0) continue; + + $hasStart = !empty($m['StartTime']); + $hasEnd = !empty($m['EndTime']); + if ($hasStart && $hasEnd) { + $startT = (new DateTime($m['StartTime']))->format('H:i'); + $endT = (new DateTime($m['EndTime']))->format('H:i'); + if ($currentTime >= $startT && $currentTime <= $endT) { + $activeMenuIds[] = (int) $m['ID']; + } + } else { + $activeMenuIds[] = (int) $m['ID']; + } + } + + if (count($activeMenuIds) === 1) { + $menuID = $activeMenuIds[0]; + } elseif (count($activeMenuIds) > 1 && $defaultMenuID > 0 && in_array($defaultMenuID, $activeMenuIds)) { + $menuID = $defaultMenuID; + } + } + } catch (Exception $e) { + // Menus table might not exist yet + } + + // Check if Categories table has data + $hasCategoriesData = false; + try { + $qCatCheck = queryOne("SELECT 1 as x FROM Categories WHERE BusinessID = ? LIMIT 1", [$businessID]); + $hasCategoriesData = $qCatCheck !== null; + } catch (Exception $e) {} + + if ($hasCategoriesData) { + // Use Categories table + $catParams = [$businessID]; + $menuFilter = ''; + if ($menuID > 0) { + $menuFilter = ' AND MenuID = ?'; + $catParams[] = $menuID; + } + + $qCatRows = queryTimed(" + SELECT ID, Name, ParentCategoryID, SortOrder, MenuID + FROM Categories + WHERE BusinessID = ? $menuFilter + ORDER BY SortOrder, Name + ", $catParams); + + $qItemRows = queryTimed(" + SELECT + i.ID, i.CategoryID as CategoryItemID, i.Name, i.Description, + i.Price, i.SortOrder, i.IsActive + FROM Items i + WHERE i.BusinessID = ? + AND i.IsActive = 1 + AND i.CategoryID > 0 + ORDER BY i.SortOrder, i.Name + ", [$businessID]); + + $qDirectModifiers = queryTimed(" + SELECT + m.ID as ItemID, m.ParentItemID, m.Name, m.Price, + m.IsCheckedByDefault as IsDefault, m.SortOrder, + m.RequiresChildSelection as RequiresSelection, + m.MaxNumSelectionReq as MaxSelections + FROM Items m + WHERE m.BusinessID = ? + AND m.IsActive = 1 + AND m.ParentItemID > 0 + AND (m.CategoryID = 0 OR m.CategoryID IS NULL) + ORDER BY m.SortOrder, m.Name + ", [$businessID]); + + } else { + // Unified schema: Categories are Items at ParentID=0 + $qCatRows = queryTimed(" + SELECT DISTINCT + p.ID, p.Name, 0 AS ParentCategoryID, p.SortOrder, 0 AS MenuID + FROM Items p + INNER JOIN Items c ON c.ParentItemID = p.ID + WHERE p.BusinessID = ? + AND p.ParentItemID = 0 + AND p.IsActive = 1 + AND NOT EXISTS ( + SELECT 1 FROM lt_ItemID_TemplateItemID tl WHERE tl.TemplateItemID = p.ID + ) + ORDER BY p.SortOrder, p.Name + ", [$businessID]); + + $qItemRows = queryTimed(" + SELECT + i.ID, i.ParentItemID as CategoryItemID, i.Name, i.Description, + i.Price, i.SortOrder, i.IsActive + FROM Items i + INNER JOIN Items cat ON cat.ID = i.ParentItemID + WHERE i.BusinessID = ? + AND i.IsActive = 1 + AND cat.ParentItemID = 0 + AND NOT EXISTS ( + SELECT 1 FROM lt_ItemID_TemplateItemID tl WHERE tl.TemplateItemID = cat.ID + ) + ORDER BY i.SortOrder, i.Name + ", [$businessID]); + + $qDirectModifiers = queryTimed(" + SELECT + m.ID as ItemID, m.ParentItemID, m.Name, m.Price, + m.IsCheckedByDefault as IsDefault, m.SortOrder, + m.RequiresChildSelection as RequiresSelection, + m.MaxNumSelectionReq as MaxSelections + FROM Items m + WHERE m.BusinessID = ? + AND m.IsActive = 1 + AND m.ParentItemID > 0 + ORDER BY m.SortOrder, m.Name + ", [$businessID]); + } + + // Collect menu item IDs + $menuItemIds = array_column($qItemRows, 'ID'); + + // Get template links for this business's menu items + $qTemplateLinkRows = []; + if (count($menuItemIds) > 0) { + $placeholders = implode(',', array_fill(0, count($menuItemIds), '?')); + $qTemplateLinkRows = queryTimed(" + SELECT tl.ItemID as ParentItemID, tl.TemplateItemID, tl.SortOrder + FROM lt_ItemID_TemplateItemID tl + WHERE tl.ItemID IN ($placeholders) + ORDER BY tl.ItemID, tl.SortOrder + ", $menuItemIds); + } + + // Get templates for this business + $qTemplateRows = queryTimed(" + SELECT DISTINCT + t.ID as ItemID, t.Name, t.Price, + t.IsCheckedByDefault as IsDefault, t.SortOrder, + t.RequiresChildSelection as RequiresSelection, + t.MaxNumSelectionReq as MaxSelections + FROM Items t + WHERE t.BusinessID = ? + AND (t.CategoryID = 0 OR t.CategoryID IS NULL) + AND t.ParentItemID = 0 + AND t.IsActive = 1 + ORDER BY t.SortOrder, t.Name + ", [$businessID]); + + $templateIds = array_column($qTemplateRows, 'ItemID'); + + // Get template children + $qTemplateChildRows = []; + if (count($templateIds) > 0) { + $placeholders = implode(',', array_fill(0, count($templateIds), '?')); + $qTemplateChildRows = queryTimed(" + SELECT + c.ID as ItemID, c.ParentItemID, c.Name, c.Price, + c.IsCheckedByDefault as IsDefault, c.SortOrder, + c.RequiresChildSelection as RequiresSelection, + c.MaxNumSelectionReq as MaxSelections + FROM Items c + WHERE c.ParentItemID IN ($placeholders) AND c.IsActive = 1 + ORDER BY c.SortOrder, c.Name + ", $templateIds); + } + + // Build templates lookup with options + $templatesById = []; + foreach ($qTemplateRows as $t) { + $tid = (int) $t['ItemID']; + $options = buildOptionsTree($qTemplateChildRows, $tid); + $templatesById[$tid] = [ + 'id' => 'mod_' . $tid, + 'dbId' => $tid, + 'name' => $t['Name'], + 'price' => $t['Price'], + 'isDefault' => (int) $t['IsDefault'] === 1, + 'sortOrder' => (int) $t['SortOrder'], + 'isTemplate' => true, + 'requiresSelection' => (int) ($t['RequiresSelection'] ?? 0) === 1, + 'maxSelections' => (int) ($t['MaxSelections'] ?? 0), + 'options' => $options, + ]; + } + + // Build template links lookup by parent ItemID + $templateLinksByItem = []; + foreach ($qTemplateLinkRows as $link) { + $parentID = (int) $link['ParentItemID']; + $templateID = (int) $link['TemplateItemID']; + + if (isset($templatesById[$templateID])) { + $tmpl = $templatesById[$templateID]; // copy + $tmpl['sortOrder'] = (int) $link['SortOrder']; + $templateLinksByItem[$parentID][] = $tmpl; + } + } + + // Build direct modifiers by item + $directModsByItem = []; + foreach ($menuItemIds as $itemId) { + $options = buildOptionsTree($qDirectModifiers, (int) $itemId); + if (count($options) > 0) { + $directModsByItem[(int) $itemId] = $options; + } + } + + // Build items lookup by CategoryID + $itemsByCategory = []; + $uploadsDir = $_SERVER['DOCUMENT_ROOT'] . '/uploads/items'; + foreach ($qItemRows as $item) { + $catID = (int) $item['CategoryItemID']; + $itemID = (int) $item['ID']; + + // Get template-linked modifiers + $itemModifiers = isset($templateLinksByItem[$itemID]) ? $templateLinksByItem[$itemID] : []; + + // Add direct modifiers + if (isset($directModsByItem[$itemID])) { + $itemModifiers = array_merge($itemModifiers, $directModsByItem[$itemID]); + } + + // Sort modifiers by sortOrder + usort($itemModifiers, fn($a, $b) => $a['sortOrder'] - $b['sortOrder']); + + // Check for existing item photo + $itemImageUrl = null; + foreach (['jpg', 'jpeg', 'png', 'gif', 'webp'] as $ext) { + if (file_exists("$uploadsDir/{$itemID}.{$ext}")) { + $itemImageUrl = "/uploads/items/{$itemID}.{$ext}"; + break; + } + } + + $itemsByCategory[$catID][] = [ + 'id' => 'item_' . $itemID, + 'dbId' => $itemID, + 'name' => $item['Name'], + 'description' => $item['Description'] ?? '', + 'price' => $item['Price'], + 'imageUrl' => $itemImageUrl, + 'photoTaskId' => null, + 'modifiers' => $itemModifiers, + 'sortOrder' => (int) $item['SortOrder'], + ]; + } + + // Build categories array + $categories = []; + $catIndex = 0; + foreach ($qCatRows as $cat) { + $catID = (int) $cat['ID']; + $catItems = $itemsByCategory[$catID] ?? []; + + $catStruct = [ + 'id' => 'cat_' . $catID, + 'dbId' => $catID, + 'name' => $cat['Name'], + 'description' => '', + 'sortOrder' => $catIndex, + 'items' => $catItems, + ]; + + if ($hasCategoriesData) { + $catStruct['menuId'] = (int) ($cat['MenuID'] ?? 0); + $catStruct['parentCategoryId'] = (int) ($cat['ParentCategoryID'] ?? 0); + $catStruct['parentCategoryDbId'] = (int) ($cat['ParentCategoryID'] ?? 0); + } + + $categories[] = $catStruct; + $catIndex++; + } + + // Build template library + $templateLibrary = array_values($templatesById); + + // Get brand colors + $brandColor = ''; + $brandColorLight = ''; + try { + $qBrand = queryOne("SELECT BrandColor, BrandColorLight FROM Businesses WHERE ID = ?", [$businessID]); + if ($qBrand) { + if (!empty($qBrand['BrandColor'])) { + $brandColor = $qBrand['BrandColor'][0] === '#' ? $qBrand['BrandColor'] : '#' . $qBrand['BrandColor']; + } + if (!empty($qBrand['BrandColorLight'])) { + $brandColorLight = $qBrand['BrandColorLight'][0] === '#' ? $qBrand['BrandColorLight'] : '#' . $qBrand['BrandColorLight']; + } + } + } catch (Exception $e) {} + + $totalItems = 0; + foreach ($categories as $cat) { + $totalItems += count($cat['items']); + } + + jsonResponse([ + 'OK' => true, + 'MENU' => ['categories' => $categories], + 'MENUS' => $allMenus, + 'SELECTED_MENU_ID' => $menuID, + 'DEFAULT_MENU_ID' => $defaultMenuID, + 'TEMPLATES' => $templateLibrary, + 'BRANDCOLOR' => $brandColor, + 'BRANDCOLORLIGHT' => $brandColorLight, + 'CATEGORY_COUNT' => count($categories), + 'TEMPLATE_COUNT' => count($templateLibrary), + 'MENU_COUNT' => count($allMenus), + 'SCHEMA' => $hasCategoriesData ? 'legacy' : 'unified', + 'ITEM_COUNT' => $totalItems, + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage(), 'DETAIL' => '']); +} diff --git a/api/menu/items.php b/api/menu/items.php new file mode 100644 index 0000000..8e847f8 --- /dev/null +++ b/api/menu/items.php @@ -0,0 +1,489 @@ + false, 'ERROR' => 'missing_businessid', 'MESSAGE' => 'BusinessID is required.', 'DETAIL' => '']); +} + +// Get business timezone for schedule filtering +$businessTimezone = 'America/Los_Angeles'; +try { + $qTz = queryOne("SELECT Timezone FROM Businesses WHERE ID = ?", [$businessID]); + if ($qTz && !empty($qTz['Timezone'])) { + $businessTimezone = $qTz['Timezone']; + } +} catch (Exception $e) { + // Column might not exist yet, use default +} + +$currentTime = getTimeInZone($businessTimezone); +$currentDayID = getDayInZone($businessTimezone); + +$menuList = []; + +try { + // Check if new schema is active (BusinessID column exists and has data) + $newSchemaActive = false; + try { + $qCheck = queryOne("SELECT COUNT(*) as cnt FROM Items WHERE BusinessID = ? AND BusinessID > 0", [$businessID]); + $newSchemaActive = $qCheck && (int) $qCheck['cnt'] > 0; + } catch (Exception $e) { + $newSchemaActive = false; + } + + $rows = []; + $qCategories = null; + + if ($newSchemaActive) { + // Check if Categories table has data for this business + $hasCategoriesData = false; + try { + $qCatCheck = queryOne("SELECT COUNT(*) as cnt FROM Categories WHERE BusinessID = ?", [$businessID]); + $hasCategoriesData = $qCatCheck && (int) $qCatCheck['cnt'] > 0; + } catch (Exception $e) { + $hasCategoriesData = false; + } + + if ($hasCategoriesData) { + // Get active menus + $activeMenuIds = ''; + try { + $qAllMenus = queryTimed(" + SELECT ID, Name FROM Menus + WHERE BusinessID = ? AND IsActive = 1 + ORDER BY SortOrder, Name + ", [$businessID]); + + if ($requestedMenuID > 0) { + $activeMenuIds = (string) $requestedMenuID; + } else { + $ids = array_column($qAllMenus, 'ID'); + $activeMenuIds = implode(',', $ids); + } + + foreach ($qAllMenus as $m) { + $menuList[] = ['MenuID' => (int) $m['ID'], 'Name' => $m['Name']]; + } + } catch (Exception $e) { + // Menus table might not exist yet + } + + // Build category query with schedule/channel/menu filtering + $catParams = [$businessID, $orderTypeID, $orderTypeID, $currentTime, $currentTime, $currentDayID]; + $menuClause = ''; + if (strlen($activeMenuIds) > 0) { + // Build safe IN clause for menu IDs + $menuIdArr = array_map('intval', explode(',', $activeMenuIds)); + $menuPlaceholders = implode(',', $menuIdArr); + $menuClause = "OR MenuID IN ($menuPlaceholders)"; + } + + $qCategories = queryTimed(" + SELECT + ID, Name, SortOrder, OrderTypes, ParentCategoryID, + ScheduleStart, ScheduleEnd, ScheduleDays, MenuID + FROM Categories + WHERE BusinessID = ? + AND (? = 0 OR FIND_IN_SET(?, OrderTypes) > 0) + AND ( + ScheduleStart IS NULL + OR ScheduleEnd IS NULL + OR (TIME(?) >= ScheduleStart AND TIME(?) <= ScheduleEnd) + ) + AND ( + ScheduleDays IS NULL + OR ScheduleDays = '' + OR FIND_IN_SET(?, ScheduleDays) > 0 + ) + AND ( + MenuID IS NULL + OR MenuID = 0 + $menuClause + ) + ORDER BY SortOrder + ", $catParams); + + // Get visible category IDs + $visibleCategoryIds = array_column($qCategories, 'ID'); + $visibleStr = count($visibleCategoryIds) > 0 + ? implode(',', array_map('intval', $visibleCategoryIds)) + : '0'; + + // Get menu items for visible categories + $q = queryTimed(" + SELECT + i.ID, + i.CategoryID, + c.Name AS CategoryName, + i.Name AS ItemName, + i.Description, + i.ParentItemID, + i.Price, + i.IsActive, + i.IsCheckedByDefault, + i.RequiresChildSelection, + i.MaxNumSelectionReq, + i.IsCollapsible, + i.SortOrder, + i.StationID, + s.Name AS StationName, + s.Color, + c.MenuID + FROM Items i + LEFT JOIN Categories c ON c.ID = i.CategoryID + LEFT JOIN Stations s ON s.ID = i.StationID + WHERE i.BusinessID = ? + AND i.IsActive = 1 + AND (i.CategoryID IN ($visibleStr) OR (i.CategoryID = 0 AND i.ParentItemID > 0)) + AND NOT EXISTS (SELECT 1 FROM lt_ItemID_TemplateItemID tl WHERE tl.TemplateItemID = i.ID) + AND NOT EXISTS (SELECT 1 FROM lt_ItemID_TemplateItemID tl2 WHERE tl2.TemplateItemID = i.ParentItemID) + AND NOT (i.ParentItemID = 0 AND i.CategoryID = 0 AND i.Price = 0) + ORDER BY COALESCE(c.SortOrder, 999), i.SortOrder, i.ID + ", [$businessID]); + + } else { + // Fallback: Derive categories from parent Items + $q = queryTimed(" + SELECT + i.ID, + CASE + WHEN i.ParentItemID = 0 AND i.IsCollapsible = 0 THEN i.ID + ELSE COALESCE( + (SELECT cat.ID FROM Items cat + WHERE cat.ID = i.ParentItemID + AND cat.ParentItemID = 0 + AND cat.IsCollapsible = 0), + 0 + ) + END as CategoryID, + CASE + WHEN i.ParentItemID = 0 AND i.IsCollapsible = 0 THEN i.Name + ELSE COALESCE( + (SELECT cat.Name FROM Items cat + WHERE cat.ID = i.ParentItemID + AND cat.ParentItemID = 0 + AND cat.IsCollapsible = 0), + '' + ) + END as CategoryName, + i.Name AS ItemName, + i.Description, + i.ParentItemID, + i.Price, + i.IsActive, + i.IsCheckedByDefault, + i.RequiresChildSelection, + i.MaxNumSelectionReq, + i.IsCollapsible, + i.SortOrder, + i.StationID, + s.Name AS StationName, + s.Color + FROM Items i + LEFT JOIN Stations s ON s.ID = i.StationID + WHERE i.BusinessID = ? + AND i.IsActive = 1 + AND NOT EXISTS (SELECT 1 FROM lt_ItemID_TemplateItemID tl WHERE tl.TemplateItemID = i.ID) + AND NOT EXISTS (SELECT 1 FROM lt_ItemID_TemplateItemID tl2 WHERE tl2.TemplateItemID = i.ParentItemID) + AND ( + i.ParentItemID > 0 + OR (i.ParentItemID = 0 AND i.IsCollapsible = 0) + ) + ORDER BY i.ParentItemID, i.SortOrder, i.ID + ", [$businessID]); + } + } else { + // OLD SCHEMA: Use Categories table + $q = queryTimed(" + SELECT + i.ID, + i.CategoryID, + c.Name AS CategoryName, + i.Name AS ItemName, + i.Description, + i.ParentItemID, + i.Price, + i.IsActive, + i.IsCheckedByDefault, + i.RequiresChildSelection, + i.MaxNumSelectionReq, + i.IsCollapsible, + i.SortOrder, + i.StationID, + s.Name AS StationName, + s.Color + FROM Items i + INNER JOIN Categories c ON c.ID = i.CategoryID + LEFT JOIN Stations s ON s.ID = i.StationID + WHERE c.BusinessID = ? + ORDER BY i.ParentItemID, i.SortOrder, i.ID + ", [$businessID]); + } + + // Build set of category IDs that have items + $categoriesWithItems = []; + foreach ($q as $row) { + if ((int) $row['CategoryID'] > 0) { + $categoriesWithItems[(int) $row['CategoryID']] = true; + } + } + + // Build category ID set for parent remapping + $categoryIdSet = []; + if ($qCategories !== null) { + // Mark parent categories of subcategories that have items + foreach ($qCategories as $cat) { + if ((int) ($cat['ParentCategoryID'] ?? 0) > 0 && isset($categoriesWithItems[(int) $cat['ID']])) { + $categoriesWithItems[(int) $cat['ParentCategoryID']] = true; + } + } + foreach ($qCategories as $cat) { + $categoryIdSet[(int) $cat['ID']] = true; + } + + // Add category headers as virtual parent items + foreach ($qCategories as $cat) { + if (isset($categoriesWithItems[(int) $cat['ID']])) { + $rows[] = [ + 'ItemID' => (int) $cat['ID'], + 'CategoryID' => (int) $cat['ID'], + 'Name' => $cat['Name'], + 'Description' => '', + 'ParentItemID' => 0, + 'ParentCategoryID' => (int) ($cat['ParentCategoryID'] ?? 0), + 'Price' => 0, + 'IsActive' => 1, + 'IsCheckedByDefault' => 0, + 'RequiresChildSelection' => 0, + 'MaxNumSelectionReq' => 0, + 'IsCollapsible' => 0, + 'SortOrder' => (int) $cat['SortOrder'], + 'MenuID' => (int) ($cat['MenuID'] ?? 0), + 'StationID' => '', + 'ItemName' => '', + 'ItemColor' => '', + ]; + } + } + } + + // Process items + foreach ($q as $row) { + $effectiveParentID = (int) $row['ParentItemID']; + + if ($newSchemaActive && $qCategories !== null && (int) $row['CategoryID'] > 0) { + if ($effectiveParentID === 0) { + $effectiveParentID = (int) $row['CategoryID']; + } elseif (isset($categoryIdSet[$effectiveParentID])) { + // Parent IS a category ID — correct + } elseif (!isset($categoryIdSet[$effectiveParentID])) { + $effectiveParentID = (int) $row['CategoryID']; + } + } + + $itemMenuID = (int) ($row['MenuID'] ?? 0); + $itemName = $row['ItemName'] ?? $row['Name'] ?? ''; + $catName = $row['CategoryName'] ?? $row['Name'] ?? ''; + + $rows[] = [ + 'ItemID' => (int) $row['ID'], + 'CategoryID' => (int) $row['CategoryID'], + 'Name' => strlen(trim($itemName)) > 0 ? $itemName : $catName, + 'Description' => $row['Description'] ?? '', + 'ParentItemID' => $effectiveParentID, + 'Price' => $row['Price'], + 'IsActive' => (int) $row['IsActive'], + 'IsCheckedByDefault' => (int) $row['IsCheckedByDefault'], + 'RequiresChildSelection' => (int) $row['RequiresChildSelection'], + 'MaxNumSelectionReq' => (int) $row['MaxNumSelectionReq'], + 'IsCollapsible' => (int) $row['IsCollapsible'], + 'SortOrder' => (int) $row['SortOrder'], + 'MenuID' => $itemMenuID, + 'StationID' => !empty($row['StationID']) ? $row['StationID'] : '', + 'ItemName' => strlen(trim($catName)) > 0 ? $catName : '', + 'ItemColor' => !empty($row['Color']) ? $row['Color'] : '', + ]; + } + + // Add template-linked modifiers as virtual children (unified schema only) + if ($newSchemaActive) { + $qTemplateLinks = queryTimed(" + SELECT + tl.ItemID as MenuItemID, + tmpl.ID as TemplateItemID, + tmpl.Name as TemplateName, + tmpl.Description as TemplateDescription, + tmpl.RequiresChildSelection as TemplateRequired, + tmpl.MaxNumSelectionReq as TemplateMaxSelections, + tmpl.IsCollapsible as TemplateIsCollapsible, + tl.SortOrder as TemplateSortOrder + FROM lt_ItemID_TemplateItemID tl + INNER JOIN Items tmpl ON tmpl.ID = tl.TemplateItemID AND tmpl.IsActive = 1 + INNER JOIN Items menuItem ON menuItem.ID = tl.ItemID + WHERE menuItem.BusinessID = ? + AND menuItem.IsActive = 1 + ORDER BY tl.ItemID, tl.SortOrder + ", [$businessID]); + + $qTemplateOptions = queryTimed(" + SELECT DISTINCT + opt.ID as OptionItemID, + opt.ParentItemID as TemplateItemID, + opt.Name as OptionName, + opt.Description as OptionDescription, + opt.Price as OptionPrice, + opt.IsCheckedByDefault as OptionIsDefault, + opt.SortOrder as OptionSortOrder + FROM Items opt + INNER JOIN lt_ItemID_TemplateItemID tl ON tl.TemplateItemID = opt.ParentItemID + INNER JOIN Items menuItem ON menuItem.ID = tl.ItemID + WHERE menuItem.BusinessID = ? + AND menuItem.IsActive = 1 + AND opt.IsActive = 1 + ORDER BY opt.ParentItemID, opt.SortOrder + ", [$businessID]); + + // Build template options map: templateID -> [options] + $templateOptionsMap = []; + foreach ($qTemplateOptions as $opt) { + $tid = (int) $opt['TemplateItemID']; + $templateOptionsMap[$tid][] = [ + 'ItemID' => (int) $opt['OptionItemID'], + 'Name' => $opt['OptionName'], + 'Description' => $opt['OptionDescription'] ?? '', + 'Price' => $opt['OptionPrice'], + 'IsCheckedByDefault' => (int) $opt['OptionIsDefault'], + 'SortOrder' => (int) $opt['OptionSortOrder'], + ]; + } + + // Add templates and options as virtual children + $addedTemplates = []; + foreach ($qTemplateLinks as $link) { + $menuItemID = (int) $link['MenuItemID']; + $templateID = (int) $link['TemplateItemID']; + $linkKey = "{$menuItemID}_{$templateID}"; + + if (isset($addedTemplates[$linkKey])) continue; + $addedTemplates[$linkKey] = true; + + $virtualTemplateID = $menuItemID * 100000 + $templateID; + + // Template as modifier group + $rows[] = [ + 'ItemID' => $virtualTemplateID, + 'CategoryID' => 0, + 'Name' => $link['TemplateName'], + 'Description' => $link['TemplateDescription'] ?? '', + 'ParentItemID' => $menuItemID, + 'Price' => 0, + 'IsActive' => 1, + 'IsCheckedByDefault' => 0, + 'RequiresChildSelection' => (int) $link['TemplateRequired'], + 'MaxNumSelectionReq' => (int) $link['TemplateMaxSelections'], + 'IsCollapsible' => (int) $link['TemplateIsCollapsible'], + 'SortOrder' => (int) $link['TemplateSortOrder'], + 'StationID' => '', + 'ItemName' => '', + 'ItemColor' => '', + ]; + + // Template options + if (isset($templateOptionsMap[$templateID])) { + foreach ($templateOptionsMap[$templateID] as $opt) { + $virtualOptionID = $menuItemID * 100000 + $opt['ItemID']; + $rows[] = [ + 'ItemID' => $virtualOptionID, + 'CategoryID' => 0, + 'Name' => $opt['Name'], + 'Description' => $opt['Description'], + 'ParentItemID' => $virtualTemplateID, + 'Price' => $opt['Price'], + 'IsActive' => 1, + 'IsCheckedByDefault' => $opt['IsCheckedByDefault'], + 'RequiresChildSelection' => 0, + 'MaxNumSelectionReq' => 0, + 'IsCollapsible' => 0, + 'SortOrder' => $opt['SortOrder'], + 'StationID' => '', + 'ItemName' => '', + 'ItemColor' => '', + ]; + } + } + } + } + + // Get brand color, tax rate, payfrit fee, header image + $qBrand = queryOne(" + SELECT BrandColor, BrandColorLight, TaxRate, PayfritFee, + HeaderImageExtension, SessionEnabled, OrderTypes + FROM Businesses WHERE ID = ? + ", [$businessID]); + + if (!$qBrand) { + apiAbort(['OK' => false, 'ERROR' => 'business_not_found']); + } + + if (!is_numeric($qBrand['TaxRate'])) { + apiAbort(['OK' => false, 'ERROR' => 'business_tax_rate_not_configured']); + } + if (!is_numeric($qBrand['PayfritFee']) || (float) $qBrand['PayfritFee'] <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'business_payfrit_fee_not_configured']); + } + + $brandColor = ''; + if (!empty($qBrand['BrandColor'])) { + $brandColor = $qBrand['BrandColor'][0] === '#' ? $qBrand['BrandColor'] : '#' . $qBrand['BrandColor']; + } + $brandColorLight = ''; + if (!empty($qBrand['BrandColorLight'])) { + $brandColorLight = $qBrand['BrandColorLight'][0] === '#' ? $qBrand['BrandColorLight'] : '#' . $qBrand['BrandColorLight']; + } + $headerImageUrl = ''; + if (!empty($qBrand['HeaderImageExtension'])) { + $headerImageUrl = "/uploads/headers/{$businessID}.{$qBrand['HeaderImageExtension']}"; + } + + jsonResponse([ + 'OK' => true, + 'ERROR' => '', + 'Items' => $rows, + 'COUNT' => count($rows), + 'SCHEMA' => $newSchemaActive ? 'unified' : 'legacy', + 'BRANDCOLOR' => $brandColor, + 'BRANDCOLORLIGHT' => $brandColorLight, + 'HEADERIMAGEURL' => $headerImageUrl, + 'TAXRATE' => (float) $qBrand['TaxRate'], + 'PAYFRITFEE' => (float) $qBrand['PayfritFee'], + 'SESSIONENABLED' => (int) ($qBrand['SessionEnabled'] ?? 0), + 'Menus' => $menuList, + 'SelectedMenuID' => $requestedMenuID, + 'ORDERTYPES' => !empty($qBrand['OrderTypes']) ? $qBrand['OrderTypes'] : '1', + ]); + +} catch (Exception $e) { + jsonResponse([ + 'OK' => false, + 'ERROR' => 'server_error', + 'MESSAGE' => 'DB error loading items', + 'DETAIL' => $e->getMessage(), + ]); +} diff --git a/api/menu/listCategories.php b/api/menu/listCategories.php new file mode 100644 index 0000000..86b677a --- /dev/null +++ b/api/menu/listCategories.php @@ -0,0 +1,51 @@ + false, 'ERROR' => 'missing_businessid', 'MESSAGE' => 'BusinessID is required']); +} + +try { + $rows = queryTimed(" + SELECT + ID, BusinessID, ParentCategoryID, Name, ImageExtension, + OrderTypes, ScheduleStart, ScheduleEnd, ScheduleDays, + SortOrder, AddedOn + FROM Categories + WHERE BusinessID = ? + ORDER BY SortOrder, Name + ", [$businessID]); + + $categories = []; + foreach ($rows as $row) { + $categories[] = [ + 'CategoryID' => (int) $row['ID'], + 'BusinessID' => (int) $row['BusinessID'], + 'ParentCategoryID' => (int) $row['ParentCategoryID'], + 'Name' => $row['Name'], + 'ImageExtension' => !empty($row['ImageExtension']) ? $row['ImageExtension'] : '', + 'OrderTypes' => $row['OrderTypes'], + 'ScheduleStart' => !empty($row['ScheduleStart']) ? (new DateTime($row['ScheduleStart']))->format('H:i:s') : '', + 'ScheduleEnd' => !empty($row['ScheduleEnd']) ? (new DateTime($row['ScheduleEnd']))->format('H:i:s') : '', + 'ScheduleDays' => $row['ScheduleDays'] ?? '', + 'SortOrder' => (int) $row['SortOrder'], + 'AddedOn' => (new DateTime($row['AddedOn']))->format('Y-m-d H:i:s'), + ]; + } + + jsonResponse(['OK' => true, 'Categories' => $categories, 'COUNT' => count($categories)]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage(), 'DETAIL' => '']); +} diff --git a/api/menu/menus.php b/api/menu/menus.php new file mode 100644 index 0000000..81481d3 --- /dev/null +++ b/api/menu/menus.php @@ -0,0 +1,181 @@ + false, 'ERROR' => 'missing_business_id', 'MESSAGE' => 'BusinessID is required']); +} + +$action = strtolower($data['action'] ?? 'list'); + +try { + switch ($action) { + + case 'list': + $qMenus = queryTimed(" + SELECT ID, Name, Description, DaysActive, StartTime, EndTime, SortOrder, IsActive + FROM Menus + WHERE BusinessID = ? AND IsActive = 1 + ORDER BY SortOrder, Name + ", [$businessID]); + + $menus = []; + foreach ($qMenus as $m) { + $catCount = queryOne(" + SELECT COUNT(*) as cnt FROM Categories + WHERE BusinessID = ? AND MenuID = ? + ", [$businessID, $m['ID']]); + + $menus[] = [ + 'MenuID' => (int) $m['ID'], + 'Name' => $m['Name'], + 'Description' => $m['Description'] ?? '', + 'DaysActive' => (int) $m['DaysActive'], + 'StartTime' => !empty($m['StartTime']) ? (new DateTime($m['StartTime']))->format('H:i') : '', + 'EndTime' => !empty($m['EndTime']) ? (new DateTime($m['EndTime']))->format('H:i') : '', + 'SortOrder' => (int) $m['SortOrder'], + 'CategoryCount' => (int) $catCount['cnt'], + ]; + } + + jsonResponse(['OK' => true, 'MENUS' => $menus, 'COUNT' => count($menus)]); + + case 'get': + $menuID = (int) ($data['MenuID'] ?? 0); + if ($menuID === 0) { + apiAbort(['OK' => false, 'ERROR' => 'missing_menu_id', 'MESSAGE' => 'MenuID is required']); + } + + $menu = queryOne("SELECT * FROM Menus WHERE ID = ? AND BusinessID = ?", [$menuID, $businessID]); + if (!$menu) { + apiAbort(['OK' => false, 'ERROR' => 'menu_not_found', 'MESSAGE' => 'Menu not found']); + } + + jsonResponse(['OK' => true, 'MENU' => [ + 'MenuID' => (int) $menu['ID'], + 'Name' => $menu['Name'], + 'Description' => $menu['Description'] ?? '', + 'DaysActive' => (int) $menu['DaysActive'], + 'StartTime' => !empty($menu['StartTime']) ? (new DateTime($menu['StartTime']))->format('H:i') : '', + 'EndTime' => !empty($menu['EndTime']) ? (new DateTime($menu['EndTime']))->format('H:i') : '', + 'SortOrder' => (int) $menu['SortOrder'], + 'IsActive' => (int) $menu['IsActive'], + ]]); + + case 'save': + $menuID = (int) ($data['MenuID'] ?? 0); + $menuName = trim($data['Name'] ?? ''); + $menuDescription = trim($data['Description'] ?? ''); + $menuDaysActive = (int) ($data['DaysActive'] ?? 127); + $menuStartTime = !empty($data['StartTime']) ? trim($data['StartTime']) : null; + $menuEndTime = !empty($data['EndTime']) ? trim($data['EndTime']) : null; + $menuSortOrder = (int) ($data['SortOrder'] ?? 0); + + if ($menuName === '') { + apiAbort(['OK' => false, 'ERROR' => 'missing_menu_name', 'MESSAGE' => 'Menu name is required']); + } + + if ($menuID > 0) { + queryTimed(" + UPDATE Menus SET + Name = ?, Description = ?, DaysActive = ?, + StartTime = ?, EndTime = ?, SortOrder = ? + WHERE ID = ? AND BusinessID = ? + ", [$menuName, $menuDescription, $menuDaysActive, $menuStartTime, $menuEndTime, $menuSortOrder, $menuID, $businessID]); + + jsonResponse(['OK' => true, 'MenuID' => $menuID, 'ACTION' => 'updated']); + } else { + queryTimed(" + INSERT INTO Menus ( + BusinessID, Name, Description, + DaysActive, StartTime, EndTime, + SortOrder, IsActive, AddedOn + ) VALUES (?, ?, ?, ?, ?, ?, ?, 1, NOW()) + ", [$businessID, $menuName, $menuDescription, $menuDaysActive, $menuStartTime, $menuEndTime, $menuSortOrder]); + + $newID = (int) lastInsertId(); + jsonResponse(['OK' => true, 'MenuID' => $newID, 'ACTION' => 'created']); + } + + case 'delete': + $menuID = (int) ($data['MenuID'] ?? 0); + if ($menuID === 0) { + apiAbort(['OK' => false, 'ERROR' => 'missing_menu_id', 'MESSAGE' => 'MenuID is required']); + } + + $catCheck = queryOne(" + SELECT COUNT(*) as cnt FROM Categories + WHERE MenuID = ? AND BusinessID = ? + ", [$menuID, $businessID]); + + if ((int) $catCheck['cnt'] > 0) { + queryTimed(" + UPDATE Categories SET MenuID = 0 + WHERE MenuID = ? AND BusinessID = ? + ", [$menuID, $businessID]); + } + + queryTimed(" + UPDATE Menus SET IsActive = 0 + WHERE ID = ? AND BusinessID = ? + ", [$menuID, $businessID]); + + jsonResponse([ + 'OK' => true, + 'MenuID' => $menuID, + 'ACTION' => 'deleted', + 'CategoriesUnassigned' => (int) $catCheck['cnt'], + ]); + + case 'reorder': + $menuOrder = $data['MenuOrder'] ?? []; + if (!is_array($menuOrder) || count($menuOrder) === 0) { + apiAbort(['OK' => false, 'ERROR' => 'missing_menu_order', 'MESSAGE' => 'MenuOrder array is required']); + } + + foreach ($menuOrder as $i => $id) { + queryTimed(" + UPDATE Menus SET SortOrder = ? + WHERE ID = ? AND BusinessID = ? + ", [$i, (int) $id, $businessID]); + } + + jsonResponse(['OK' => true, 'ACTION' => 'reordered']); + + case 'setdefault': + $menuID = (int) ($data['MenuID'] ?? 0); + + if ($menuID > 0) { + $check = queryOne(" + SELECT ID FROM Menus WHERE ID = ? AND BusinessID = ? AND IsActive = 1 + ", [$menuID, $businessID]); + + if (!$check) { + apiAbort(['OK' => false, 'ERROR' => 'invalid_menu', 'MESSAGE' => 'Menu not found or not active']); + } + } + + queryTimed(" + UPDATE Businesses SET DefaultMenuID = ? + WHERE ID = ? + ", [$menuID === 0 ? null : $menuID, $businessID]); + + jsonResponse(['OK' => true, 'ACTION' => 'defaultSet', 'DefaultMenuID' => $menuID]); + + default: + apiAbort(['OK' => false, 'ERROR' => 'invalid_action', 'MESSAGE' => 'Unknown action: ' . $action]); + } + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage(), 'DETAIL' => '']); +} diff --git a/api/menu/saveCategory.php b/api/menu/saveCategory.php new file mode 100644 index 0000000..8572410 --- /dev/null +++ b/api/menu/saveCategory.php @@ -0,0 +1,80 @@ + 0) { + $parent = queryOne("SELECT ParentCategoryID FROM Categories WHERE ID = ?", [$parentCategoryID]); + if (!$parent) { + jsonResponse(['OK' => false, 'ERROR' => 'invalid_parent', 'MESSAGE' => 'Parent category not found']); + } + if ((int) $parent['ParentCategoryID'] > 0) { + jsonResponse(['OK' => false, 'ERROR' => 'nesting_too_deep', 'MESSAGE' => 'Subcategories cannot have their own subcategories (max 2 levels)']); + } + } + + if ($categoryID > 0) { + // Update existing category + queryTimed(" + UPDATE Categories SET + Name = ?, + SortOrder = ?, + OrderTypes = ?, + ParentCategoryID = ?, + ScheduleStart = ?, + ScheduleEnd = ?, + ScheduleDays = ? + WHERE ID = ? + ", [$name, $sortOrder, $orderTypes, $parentCategoryID, $scheduleStart, $scheduleEnd, $scheduleDays, $categoryID]); + + jsonResponse(['OK' => true, 'CategoryID' => $categoryID, 'MESSAGE' => 'Category updated']); + + } elseif ($businessID > 0 && strlen($name) > 0) { + // Insert new category + queryTimed(" + INSERT INTO Categories + (BusinessID, Name, SortOrder, OrderTypes, ParentCategoryID, + ScheduleStart, ScheduleEnd, ScheduleDays, AddedOn) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW()) + ", [$businessID, $name, $sortOrder, $orderTypes, $parentCategoryID, $scheduleStart, $scheduleEnd, $scheduleDays]); + + $newId = lastInsertId(); + jsonResponse(['OK' => true, 'CategoryID' => (int) $newId, 'MESSAGE' => 'Category created']); + + } else { + jsonResponse(['OK' => false, 'ERROR' => 'invalid_params', 'MESSAGE' => 'CategoryID required for update, or BusinessID and Name for insert']); + } + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage(), 'DETAIL' => '']); +} diff --git a/api/menu/saveFromBuilder.php b/api/menu/saveFromBuilder.php new file mode 100644 index 0000000..5ac06c5 --- /dev/null +++ b/api/menu/saveFromBuilder.php @@ -0,0 +1,258 @@ + 0) { + $optionID = $optDbId; + queryTimed(" + UPDATE Items + SET Name = ?, Price = ?, IsCheckedByDefault = ?, SortOrder = ?, + RequiresChildSelection = ?, MaxNumSelectionReq = ?, ParentItemID = ? + WHERE ID = ? + ", [ + $opt['name'], (float) ($opt['price'] ?? 0), $isDefault, $optSortOrder, + $requiresSelection, $maxSelections, $parentID, $optDbId, + ]); + } else { + queryTimed(" + INSERT INTO Items ( + BusinessID, ParentItemID, Name, Price, + IsCheckedByDefault, SortOrder, IsActive, AddedOn, + RequiresChildSelection, MaxNumSelectionReq, CategoryID + ) VALUES (?, ?, ?, ?, ?, ?, 1, NOW(), ?, ?, 0) + ", [ + $businessID, $parentID, $opt['name'], (float) ($opt['price'] ?? 0), + $isDefault, $optSortOrder, $requiresSelection, $maxSelections, + ]); + $optionID = (int) lastInsertId(); + } + + if (!empty($opt['options']) && is_array($opt['options'])) { + saveOptionsRecursive($opt['options'], $optionID, $businessID); + } + + $optSortOrder++; + } +} + +$data = readJsonBody(); +$businessID = (int) ($data['BusinessID'] ?? 0); +$menu = $data['Menu'] ?? []; + +if ($businessID === 0) { + jsonResponse(['OK' => false, 'ERROR' => 'BusinessID is required']); +} + +if (!isset($menu['categories']) || !is_array($menu['categories'])) { + jsonResponse(['OK' => false, 'ERROR' => 'Menu categories are required']); +} + +try { + // Determine schema + $hasCategoriesData = false; + try { + $qCatCheck = queryOne("SELECT 1 as x FROM Categories WHERE BusinessID = ? LIMIT 1", [$businessID]); + $hasCategoriesData = $qCatCheck !== null; + } catch (Exception $e) {} + $newSchemaActive = !$hasCategoriesData; + + $db = getDb(); + $db->beginTransaction(); + + // Track JS category id -> DB categoryID for subcategory parent resolution + $jsCatIdToDbId = []; + + $catSortOrder = 0; + foreach ($menu['categories'] as $cat) { + $categoryID = 0; + $categoryDbId = (int) ($cat['dbId'] ?? 0); + $categoryMenuId = (int) ($cat['menuId'] ?? 0); + + // Resolve parentCategoryID + $parentCategoryID = 0; + if (!empty($cat['parentCategoryDbId']) && (int) $cat['parentCategoryDbId'] > 0) { + $parentCategoryID = (int) $cat['parentCategoryDbId']; + } elseif (!empty($cat['parentCategoryId']) && $cat['parentCategoryId'] !== '0') { + if (isset($jsCatIdToDbId[$cat['parentCategoryId']])) { + $parentCategoryID = $jsCatIdToDbId[$cat['parentCategoryId']]; + } + } + + if ($newSchemaActive) { + if ($categoryDbId > 0) { + $categoryID = $categoryDbId; + queryTimed(" + UPDATE Items SET Name = ?, SortOrder = ? + WHERE ID = ? AND BusinessID = ? + ", [$cat['name'], $catSortOrder, $categoryID, $businessID]); + } else { + queryTimed(" + INSERT INTO Items (BusinessID, Name, Description, ParentItemID, Price, IsActive, SortOrder, AddedOn, CategoryID) + VALUES (?, ?, '', 0, 0, 1, ?, NOW(), 0) + ", [$businessID, $cat['name'], $catSortOrder]); + $categoryID = (int) lastInsertId(); + } + } else { + if ($categoryDbId > 0) { + $categoryID = $categoryDbId; + queryTimed(" + UPDATE Categories + SET Name = ?, SortOrder = ?, MenuID = NULLIF(?, 0), ParentCategoryID = ? + WHERE ID = ? + ", [$cat['name'], $catSortOrder, $categoryMenuId, $parentCategoryID, $categoryID]); + } else { + queryTimed(" + INSERT INTO Categories (BusinessID, MenuID, Name, SortOrder, ParentCategoryID, AddedOn) + VALUES (?, NULLIF(?, 0), ?, ?, ?, NOW()) + ", [$businessID, $categoryMenuId, $cat['name'], $catSortOrder, $parentCategoryID]); + $categoryID = (int) lastInsertId(); + } + } + + // Track JS id -> DB id + if (!empty($cat['id'])) { + $jsCatIdToDbId[$cat['id']] = $categoryID; + } + + // Process items + if (!empty($cat['items']) && is_array($cat['items'])) { + $itemSortOrder = 0; + foreach ($cat['items'] as $item) { + $itemID = 0; + $itemDbId = (int) ($item['dbId'] ?? 0); + + if ($itemDbId > 0) { + $itemID = $itemDbId; + if ($newSchemaActive) { + queryTimed(" + UPDATE Items SET Name = ?, Description = ?, Price = ?, ParentItemID = ?, SortOrder = ? + WHERE ID = ? + ", [$item['name'], $item['description'] ?? '', (float) ($item['price'] ?? 0), $categoryID, $itemSortOrder, $itemID]); + } else { + queryTimed(" + UPDATE Items SET Name = ?, Description = ?, Price = ?, CategoryID = ?, SortOrder = ? + WHERE ID = ? + ", [$item['name'], $item['description'] ?? '', (float) ($item['price'] ?? 0), $categoryID, $itemSortOrder, $itemID]); + } + } else { + if ($newSchemaActive) { + queryTimed(" + INSERT INTO Items (BusinessID, ParentItemID, Name, Description, Price, SortOrder, IsActive, AddedOn, CategoryID) + VALUES (?, ?, ?, ?, ?, ?, 1, NOW(), 0) + ", [$businessID, $categoryID, $item['name'], $item['description'] ?? '', (float) ($item['price'] ?? 0), $itemSortOrder]); + } else { + queryTimed(" + INSERT INTO Items (BusinessID, CategoryID, Name, Description, Price, SortOrder, IsActive, ParentItemID, AddedOn) + VALUES (?, ?, ?, ?, ?, ?, 1, 0, NOW()) + ", [$businessID, $categoryID, $item['name'], $item['description'] ?? '', (float) ($item['price'] ?? 0), $itemSortOrder]); + } + $itemID = (int) lastInsertId(); + } + + // Handle modifiers + if (!empty($item['modifiers']) && is_array($item['modifiers'])) { + // Clear existing template links for this item + queryTimed("DELETE FROM lt_ItemID_TemplateItemID WHERE ItemID = ?", [$itemID]); + + $modSortOrder = 0; + foreach ($item['modifiers'] as $mod) { + $modDbId = (int) ($mod['dbId'] ?? 0); + $requiresSelection = (!empty($mod['requiresSelection'])) ? 1 : 0; + $maxSelections = (int) ($mod['maxSelections'] ?? 0); + + if (!empty($mod['isTemplate']) && $modDbId > 0) { + // Template reference — create link + queryTimed(" + INSERT INTO lt_ItemID_TemplateItemID (ItemID, TemplateItemID, SortOrder) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE SortOrder = ? + ", [$itemID, $modDbId, $modSortOrder, $modSortOrder]); + + // Update template name and selection rules + queryTimed(" + UPDATE Items SET Name = ?, RequiresChildSelection = ?, MaxNumSelectionReq = ? + WHERE ID = ? + ", [$mod['name'], $requiresSelection, $maxSelections, $modDbId]); + + // Save template options only once + if (!isset($savedTemplates[$modDbId])) { + $savedTemplates[$modDbId] = true; + if (!empty($mod['options']) && is_array($mod['options'])) { + saveOptionsRecursive($mod['options'], $modDbId, $businessID); + } + } + + } elseif ($modDbId > 0) { + // Direct modifier — update + $isDefault = (!empty($mod['isDefault'])) ? 1 : 0; + queryTimed(" + UPDATE Items + SET Name = ?, Price = ?, IsCheckedByDefault = ?, SortOrder = ?, + RequiresChildSelection = ?, MaxNumSelectionReq = ?, ParentItemID = ? + WHERE ID = ? + ", [ + $mod['name'], (float) ($mod['price'] ?? 0), $isDefault, $modSortOrder, + $requiresSelection, $maxSelections, $itemID, $modDbId, + ]); + + if (!empty($mod['options']) && is_array($mod['options'])) { + saveOptionsRecursive($mod['options'], $modDbId, $businessID); + } + + } else { + // New direct modifier — insert + $isDefault = (!empty($mod['isDefault'])) ? 1 : 0; + queryTimed(" + INSERT INTO Items ( + BusinessID, ParentItemID, Name, Price, + IsCheckedByDefault, SortOrder, IsActive, AddedOn, + RequiresChildSelection, MaxNumSelectionReq, CategoryID + ) VALUES (?, ?, ?, ?, ?, ?, 1, NOW(), ?, ?, 0) + ", [ + $businessID, $itemID, $mod['name'], (float) ($mod['price'] ?? 0), + $isDefault, $modSortOrder, $requiresSelection, $maxSelections, + ]); + + $newModID = (int) lastInsertId(); + if (!empty($mod['options']) && is_array($mod['options'])) { + saveOptionsRecursive($mod['options'], $newModID, $businessID); + } + } + $modSortOrder++; + } + } + $itemSortOrder++; + } + } + $catSortOrder++; + } + + $db->commit(); + + jsonResponse(['OK' => true, 'SCHEMA' => $newSchemaActive ? 'unified' : 'legacy']); + +} catch (Exception $e) { + try { $db->rollBack(); } catch (Exception $re) {} + jsonResponse(['OK' => false, 'ERROR' => $e->getMessage(), 'DETAIL' => '', 'TYPE' => '']); +} diff --git a/api/menu/updateStations.php b/api/menu/updateStations.php new file mode 100644 index 0000000..e5e4cfb --- /dev/null +++ b/api/menu/updateStations.php @@ -0,0 +1,57 @@ + false, 'ERROR' => 'missing_businessid', 'MESSAGE' => 'BusinessID is required.']); +} + +try { + $updateCount = 0; + + // Clear all station assignments for items in this business + queryTimed("UPDATE Items SET StationID = NULL WHERE BusinessID = ?", [$businessID]); + + // Apply new assignments + foreach ($assignments as $assignment) { + if (isset($assignment['ItemID'], $assignment['StationID'])) { + queryTimed("UPDATE Items SET StationID = ? WHERE ID = ?", [ + (int) $assignment['StationID'], + (int) $assignment['ItemID'], + ]); + $updateCount++; + } + } + + jsonResponse([ + 'OK' => true, + 'ERROR' => '', + 'MESSAGE' => 'Station assignments updated', + 'UPDATED_COUNT' => $updateCount, + ]); + +} catch (Exception $e) { + jsonResponse([ + 'OK' => false, + 'ERROR' => 'server_error', + 'MESSAGE' => 'Error updating stations', + 'DETAIL' => $e->getMessage(), + ]); +} diff --git a/api/menu/uploadHeader.php b/api/menu/uploadHeader.php new file mode 100644 index 0000000..a6a8fc7 --- /dev/null +++ b/api/menu/uploadHeader.php @@ -0,0 +1,105 @@ + false, 'ERROR' => 'missing_businessid', 'MESSAGE' => 'BusinessID is required']); +} + +if (!isset($_FILES['header']) || $_FILES['header']['error'] !== UPLOAD_ERR_OK) { + jsonResponse(['OK' => false, 'ERROR' => 'no_file', 'MESSAGE' => 'No file was uploaded']); +} + +$headersDir = $_SERVER['DOCUMENT_ROOT'] . '/uploads/headers'; +if (!is_dir($headersDir)) { + mkdir($headersDir, 0755, true); +} + +try { + $tmpFile = $_FILES['header']['tmp_name']; + + // Detect actual format from file contents (magic bytes) + $actualExt = ''; + $fh = fopen($tmpFile, 'rb'); + $header = fread($fh, 8); + fclose($fh); + $hex = strtoupper(bin2hex($header)); + + if (str_starts_with($hex, 'FFD8')) { + $actualExt = 'jpg'; + } elseif (str_starts_with($hex, '89504E470D0A1A0A')) { + $actualExt = 'png'; + } elseif (str_starts_with($hex, '474946')) { + $actualExt = 'gif'; + } elseif (str_starts_with($hex, '52494646')) { + // RIFF container — could be WEBP + $actualExt = 'webp'; + } + + // Fallback to client extension + if ($actualExt === '') { + $actualExt = strtolower(pathinfo($_FILES['header']['name'], PATHINFO_EXTENSION)); + } + + $allowed = ['jpg', 'jpeg', 'gif', 'png', 'webp', 'heic', 'heif']; + if (!in_array($actualExt, $allowed)) { + jsonResponse(['OK' => false, 'ERROR' => 'invalid_type', 'MESSAGE' => 'Only image files are accepted (jpg, jpeg, gif, png, webp, heic)']); + } + + // Convert HEIC/HEIF extension to jpg for consistency + if ($actualExt === 'heic' || $actualExt === 'heif') { + $actualExt = 'jpg'; + } + + // Delete old header if exists + $old = queryOne("SELECT HeaderImageExtension FROM Businesses WHERE ID = ?", [$bizId]); + if ($old && !empty($old['HeaderImageExtension'])) { + $oldFile = "$headersDir/{$bizId}.{$old['HeaderImageExtension']}"; + if (file_exists($oldFile)) { + @unlink($oldFile); + } + } + + // Delete destination file if it already exists (same extension re-upload) + $destFile = "$headersDir/{$bizId}.{$actualExt}"; + if (file_exists($destFile)) { + @unlink($destFile); + } + + // Move uploaded file + if (!move_uploaded_file($tmpFile, $destFile)) { + jsonResponse(['OK' => false, 'ERROR' => 'upload_failed', 'MESSAGE' => 'Failed to save uploaded file']); + } + + // Update database + queryTimed("UPDATE Businesses SET HeaderImageExtension = ? WHERE ID = ?", [$actualExt, $bizId]); + + // Get image dimensions + $imgSize = @getimagesize($destFile); + $width = $imgSize[0] ?? 0; + $height = $imgSize[1] ?? 0; + + jsonResponse([ + 'OK' => true, + 'ERROR' => '', + 'MESSAGE' => 'Header uploaded successfully', + 'HEADERURL' => "/uploads/headers/{$bizId}.{$actualExt}", + 'WIDTH' => $width, + 'HEIGHT' => $height, + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage(), 'DETAIL' => '']); +} diff --git a/api/menu/uploadItemPhoto.php b/api/menu/uploadItemPhoto.php new file mode 100644 index 0000000..f3f4e6d --- /dev/null +++ b/api/menu/uploadItemPhoto.php @@ -0,0 +1,152 @@ + false, 'ERROR' => 'missing_itemid', 'MESSAGE' => 'ItemID is required']); +} + +if (!isset($_FILES['photo']) || $_FILES['photo']['error'] !== UPLOAD_ERR_OK) { + jsonResponse(['OK' => false, 'ERROR' => 'no_file', 'MESSAGE' => 'No file was uploaded']); +} + +$allowedExtensions = ['jpg', 'jpeg', 'gif', 'png', 'webp', 'heic', 'heif']; +$ext = strtolower(pathinfo($_FILES['photo']['name'], PATHINFO_EXTENSION)); +if (!in_array($ext, $allowedExtensions)) { + jsonResponse(['OK' => false, 'ERROR' => 'invalid_type', 'MESSAGE' => "Only image files are accepted (jpg, jpeg, gif, png, webp, heic). Got: $ext"]); +} + +// Determine uploads directory (server path) +$itemsDir = $_SERVER['DOCUMENT_ROOT'] . '/uploads/items'; +if (!is_dir($itemsDir)) { + mkdir($itemsDir, 0755, true); +} + +try { + $tmpFile = $_FILES['photo']['tmp_name']; + + // Delete old photos and thumbnails + foreach (['jpg', 'jpeg', 'gif', 'png', 'webp'] as $oldExt) { + foreach (['', '_thumb', '_medium'] as $suffix) { + $oldFile = "$itemsDir/{$itemId}{$suffix}.{$oldExt}"; + if (file_exists($oldFile)) { + @unlink($oldFile); + } + } + } + + // Load image with GD + $srcImage = null; + $mime = mime_content_type($tmpFile); + switch ($mime) { + case 'image/jpeg': $srcImage = imagecreatefromjpeg($tmpFile); break; + case 'image/png': $srcImage = imagecreatefrompng($tmpFile); break; + case 'image/gif': $srcImage = imagecreatefromgif($tmpFile); break; + case 'image/webp': $srcImage = imagecreatefromwebp($tmpFile); break; + default: + // Try JPEG as fallback (HEIC converted by server) + $srcImage = @imagecreatefromjpeg($tmpFile); + } + + if (!$srcImage) { + jsonResponse(['OK' => false, 'ERROR' => 'invalid_image', 'MESSAGE' => 'Could not process image file']); + } + + // Fix EXIF orientation for JPEG + if (function_exists('exif_read_data') && in_array($mime, ['image/jpeg', 'image/tiff'])) { + $exif = @exif_read_data($tmpFile); + if ($exif && isset($exif['Orientation'])) { + switch ($exif['Orientation']) { + case 3: $srcImage = imagerotate($srcImage, 180, 0); break; + case 6: $srcImage = imagerotate($srcImage, -90, 0); break; + case 8: $srcImage = imagerotate($srcImage, 90, 0); break; + } + } + } + + $origW = imagesx($srcImage); + $origH = imagesy($srcImage); + + // Helper: resize to fit within maxSize box + $resizeToFit = function($img, int $maxSize) { + $w = imagesx($img); + $h = imagesy($img); + if ($w <= $maxSize && $h <= $maxSize) return $img; + + if ($w > $h) { + $newW = $maxSize; + $newH = (int) ($h * ($maxSize / $w)); + } else { + $newH = $maxSize; + $newW = (int) ($w * ($maxSize / $h)); + } + $resized = imagecreatetruecolor($newW, $newH); + imagecopyresampled($resized, $img, 0, 0, 0, 0, $newW, $newH, $w, $h); + return $resized; + }; + + // Helper: create square center-crop thumbnail + $createSquareThumb = function($img, int $size) { + $w = imagesx($img); + $h = imagesy($img); + + // Resize so smallest dimension equals size + if ($w > $h) { + $newH = $size; + $newW = (int) ($w * ($size / $h)); + } else { + $newW = $size; + $newH = (int) ($h * ($size / $w)); + } + $resized = imagecreatetruecolor($newW, $newH); + imagecopyresampled($resized, $img, 0, 0, 0, 0, $newW, $newH, $w, $h); + + // Center crop to square + $x = (int) (($newW - $size) / 2); + $y = (int) (($newH - $size) / 2); + $thumb = imagecreatetruecolor($size, $size); + imagecopy($thumb, $resized, 0, 0, $x, $y, $size, $size); + imagedestroy($resized); + return $thumb; + }; + + // Create thumbnail (128x128 square for retina) + $thumb = $createSquareThumb($srcImage, 128); + imagejpeg($thumb, "$itemsDir/{$itemId}_thumb.jpg", 85); + imagedestroy($thumb); + + // Create medium (400px max) + $medium = $resizeToFit($srcImage, 400); + imagejpeg($medium, "$itemsDir/{$itemId}_medium.jpg", 85); + if ($medium !== $srcImage) imagedestroy($medium); + + // Save full size (1200px max) + $full = $resizeToFit($srcImage, 1200); + imagejpeg($full, "$itemsDir/{$itemId}.jpg", 90); + if ($full !== $srcImage) imagedestroy($full); + + imagedestroy($srcImage); + + $cacheBuster = date('YmdHis'); + + jsonResponse([ + 'OK' => true, + 'ERROR' => '', + 'MESSAGE' => 'Photo uploaded successfully', + 'IMAGEURL' => "/uploads/items/{$itemId}.jpg?v={$cacheBuster}", + 'THUMBURL' => "/uploads/items/{$itemId}_thumb.jpg?v={$cacheBuster}", + 'MEDIUMURL' => "/uploads/items/{$itemId}_medium.jpg?v={$cacheBuster}", + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage(), 'DETAIL' => '']); +} diff --git a/api/orders/_cartPayload.php b/api/orders/_cartPayload.php new file mode 100644 index 0000000..d31193a --- /dev/null +++ b/api/orders/_cartPayload.php @@ -0,0 +1,91 @@ + false, 'ERROR' => 'not_found', 'MESSAGE' => 'Order not found', 'DETAIL' => '']; + } + + $order = [ + 'OrderID' => (int) $qOrder['ID'], + 'UUID' => $qOrder['UUID'] ?? '', + 'UserID' => (int) $qOrder['UserID'], + 'BusinessID' => (int) $qOrder['BusinessID'], + 'DeliveryMultiplier' => (float) $qOrder['DeliveryMultiplier'], + 'OrderTypeID' => (int) $qOrder['OrderTypeID'], + 'DeliveryFee' => (float) $qOrder['DeliveryFee'], + 'BusinessDeliveryFee' => (float) $qOrder['BusinessDeliveryFee'], + 'TaxRate' => (float) $qOrder['TaxRate'], + 'PayfritFee' => (float) $qOrder['PayfritFee'], + 'StatusID' => (int) $qOrder['StatusID'], + 'AddressID' => (int) ($qOrder['AddressID'] ?? 0), + 'PaymentID' => (int) ($qOrder['PaymentID'] ?? 0), + 'Remarks' => $qOrder['Remarks'] ?? '', + 'AddedOn' => $qOrder['AddedOn'], + 'LastEditedOn' => $qOrder['LastEditedOn'], + 'SubmittedOn' => $qOrder['SubmittedOn'], + 'ServicePointID' => (int) ($qOrder['ServicePointID'] ?? 0), + 'ServicePointName' => $qOrder['ServicePointName'] ?? '', + 'GrantID' => (int) ($qOrder['GrantID'] ?? 0), + 'GrantOwnerBusinessID' => (int) ($qOrder['GrantOwnerBusinessID'] ?? 0), + 'GrantEconomicsType' => $qOrder['GrantEconomicsType'] ?? '', + 'GrantEconomicsValue' => (float) ($qOrder['GrantEconomicsValue'] ?? 0), + 'TabID' => (int) ($qOrder['TabID'] ?? 0), + ]; + + $qLI = queryTimed(" + SELECT + oli.ID, oli.ParentOrderLineItemID, oli.OrderID, oli.ItemID, + oli.StatusID, oli.Price, oli.Quantity, oli.Remark, oli.IsDeleted, oli.AddedOn, + i.Name, i.ParentItemID, i.IsCheckedByDefault, + parent.Name AS ItemParentName + FROM OrderLineItems oli + INNER JOIN Items i ON i.ID = oli.ItemID + LEFT JOIN Items parent ON parent.ID = i.ParentItemID + WHERE oli.OrderID = ? + ORDER BY oli.ID + ", [$OrderID]); + + $rows = []; + foreach ($qLI as $r) { + $rows[] = [ + 'OrderLineItemID' => (int) $r['ID'], + 'ParentOrderLineItemID' => (int) $r['ParentOrderLineItemID'], + 'OrderID' => (int) $r['OrderID'], + 'ItemID' => (int) $r['ItemID'], + 'StatusID' => (int) $r['StatusID'], + 'Price' => (float) $r['Price'], + 'Quantity' => (int) $r['Quantity'], + 'Remark' => $r['Remark'] ?? '', + 'IsDeleted' => (int) $r['IsDeleted'], + 'AddedOn' => $r['AddedOn'], + 'Name' => $r['Name'] ?? '', + 'ParentItemID' => (int) $r['ParentItemID'], + 'ItemParentName' => $r['ItemParentName'] ?? '', + 'IsCheckedByDefault' => (int) $r['IsCheckedByDefault'], + ]; + } + + return ['OK' => true, 'ERROR' => '', 'ORDER' => $order, 'ORDERLINEITEMS' => $rows]; +} diff --git a/api/orders/_createOrderTasks.php b/api/orders/_createOrderTasks.php new file mode 100644 index 0000000..d4dcdf1 --- /dev/null +++ b/api/orders/_createOrderTasks.php @@ -0,0 +1,137 @@ + 0) { + $spName = $qOrder['Name'] ?? ''; + + if ($orderTypeID == 1) { + $tableName = strlen($spName) ? $spName : 'Table'; + $taskTitle = "Deliver Order #{$OrderID} to {$tableName}"; + $taskCategoryID = 3; + } elseif ($orderTypeID == 2) { + $taskTitle = "Order #{$OrderID} Ready for Pickup"; + $taskCategoryID = 4; + } elseif ($orderTypeID == 3) { + $taskTitle = "Deliver Order #{$OrderID} to Address"; + $taskCategoryID = 5; + } else { + $taskTitle = ''; + $taskCategoryID = 0; + } + + if ($taskTitle !== '') { + $spID = (int) ($qOrder['ServicePointID'] ?? 0); + queryTimed( + "INSERT INTO Tasks (BusinessID, OrderID, ServicePointID, TaskTypeID, CategoryID, Title, ClaimedByUserID, CreatedOn) + VALUES (?, ?, ?, ?, ?, ?, 0, NOW())", + [ + $qOrder['BusinessID'], + $OrderID, + $spID > 0 ? $spID : null, + $taskTypeID, + $taskCategoryID, + $taskTitle + ] + ); + $taskCreated = true; + } + } + + // Check for pending cash payment and create "Pay With Cash" task + $qCashPayment = queryOne( + "SELECT p.PaymentPaidInCash, o.PaymentStatus, o.ServicePointID, sp.Name AS ServicePointName + FROM Orders o + LEFT JOIN Payments p ON p.PaymentID = o.PaymentID + LEFT JOIN ServicePoints sp ON sp.ID = o.ServicePointID + WHERE o.ID = ?", + [$OrderID] + ); + + if ($qCashPayment + && (float) ($qCashPayment['PaymentPaidInCash'] ?? 0) > 0 + && ($qCashPayment['PaymentStatus'] ?? '') === 'pending' + ) { + // Check if there's already an active cash task + $qExistingCash = queryOne( + "SELECT t.ID FROM Tasks t + INNER JOIN tt_TaskTypes tt ON tt.ID = t.TaskTypeID + WHERE t.OrderID = ? AND tt.Name LIKE '%Cash%' AND t.CompletedOn IS NULL + LIMIT 1", + [$OrderID] + ); + + if (!$qExistingCash) { + $qCashType = queryOne( + "SELECT ID FROM tt_TaskTypes WHERE BusinessID = ? AND Name LIKE '%Cash%' LIMIT 1", + [$qOrder['BusinessID']] + ); + $cashTaskTypeID = $qCashType ? (int) $qCashType['ID'] : 0; + $cashTitle = "Pay With Cash - Order #{$OrderID}"; + $spName2 = $qCashPayment['ServicePointName'] ?? ''; + if (strlen($spName2)) { + $cashTitle .= " ({$spName2})"; + } + $cashSPID = (int) ($qCashPayment['ServicePointID'] ?? 0); + queryTimed( + "INSERT INTO Tasks (BusinessID, OrderID, TaskTypeID, Title, ClaimedByUserID, CreatedOn, ServicePointID) + VALUES (?, ?, ?, ?, 0, NOW(), ?)", + [ + $qOrder['BusinessID'], + $OrderID, + $cashTaskTypeID, + $cashTitle, + $cashSPID > 0 ? $cashSPID : null + ] + ); + $cashTaskCreated = true; + } + } + } catch (Exception $e) { + // Task creation failed, but don't fail the status update + } +} diff --git a/api/orders/abandonOrder.php b/api/orders/abandonOrder.php new file mode 100644 index 0000000..81ca634 --- /dev/null +++ b/api/orders/abandonOrder.php @@ -0,0 +1,38 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'OrderID is required.']); +} + +try { + $qOrder = queryOne("SELECT ID, StatusID FROM Orders WHERE ID = ? LIMIT 1", [$OrderID]); + + if (!$qOrder) { + apiAbort(['OK' => false, 'ERROR' => 'not_found', 'MESSAGE' => 'Order not found.']); + } + + if ((int) $qOrder['StatusID'] !== 0) { + apiAbort(['OK' => false, 'ERROR' => 'invalid_status', 'MESSAGE' => 'Only cart orders can be abandoned.']); + } + + // Delete line items + queryTimed("DELETE FROM OrderLineItems WHERE OrderID = ?", [$OrderID]); + + // Mark order with status 7 (Deleted) + queryTimed("UPDATE Orders SET StatusID = 7, LastEditedOn = NOW() WHERE ID = ?", [$OrderID]); + + jsonResponse(['OK' => true, 'MESSAGE' => 'Order abandoned successfully.']); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => 'Failed to abandon order: ' . $e->getMessage()]); +} diff --git a/api/orders/checkStatusUpdate.php b/api/orders/checkStatusUpdate.php new file mode 100644 index 0000000..0f05a38 --- /dev/null +++ b/api/orders/checkStatusUpdate.php @@ -0,0 +1,72 @@ + false, 'ERROR' => '', 'HAS_UPDATE' => false, 'ORDER_STATUS' => new stdClass(), 'MESSAGE' => '']; + +try { + $data = readJsonBody(); + + if (!isset($data['OrderID'])) { + $payload['ERROR'] = 'OrderID is required'; + jsonResponse($payload); + } + if (!isset($data['LastKnownStatusID'])) { + $payload['ERROR'] = 'LastKnownStatusID is required'; + jsonResponse($payload); + } + + $OrderID = (int) $data['OrderID']; + $LastKnownStatusID = (int) $data['LastKnownStatusID']; + + $qOrder = queryOne(" + SELECT ID, StatusID, LastEditedOn, ServicePointID, BusinessID, UserID + FROM Orders + WHERE ID = ? AND StatusID != 7 + ", [$OrderID]); + + if (!$qOrder) { + $payload['ERROR'] = 'Order not found or deleted'; + jsonResponse($payload); + } + + $currentStatus = (int) $qOrder['StatusID']; + $hasUpdate = ($currentStatus !== $LastKnownStatusID); + + $payload['OK'] = true; + $payload['HAS_UPDATE'] = $hasUpdate; + + if ($hasUpdate) { + $statusMap = [ + 0 => ['Cart', 'Your order is in the cart'], + 1 => ['Submitted', 'Your order has been received and is being prepared'], + 2 => ['Preparing', 'Your order is now being prepared'], + 3 => ['Ready', 'Your order is ready for pickup!'], + 4 => ['Completed', 'Thank you! Your order is complete'], + ]; + + $info = $statusMap[$currentStatus] ?? ['Unknown', 'Order status updated']; + + $payload['ORDER_STATUS'] = [ + 'OrderID' => (int) $qOrder['ID'], + 'StatusID' => $currentStatus, + 'StatusName' => $info[0], + 'Message' => $info[1], + 'LastEditedOn' => toISO8601($qOrder['LastEditedOn']), + ]; + $payload['MESSAGE'] = $info[1]; + } else { + $payload['MESSAGE'] = 'No status update'; + } + +} catch (Exception $e) { + $payload['ERROR'] = 'Error checking status: ' . $e->getMessage(); + $payload['OK'] = false; +} + +jsonResponse($payload); diff --git a/api/orders/getActiveCart.php b/api/orders/getActiveCart.php new file mode 100644 index 0000000..6345ce6 --- /dev/null +++ b/api/orders/getActiveCart.php @@ -0,0 +1,68 @@ + false, 'ERROR' => 'UserID is required']); +} + +$sql = " + SELECT + o.ID AS OrderID, + o.UUID AS OrderUUID, + o.BusinessID, + b.Name AS BusinessName, + o.OrderTypeID, + COALESCE(ot.Name, 'Undecided') AS OrderTypeName, + o.ServicePointID, + COALESCE(sp.Name, '') AS ServicePointName, + (SELECT COUNT(*) FROM OrderLineItems oli + WHERE oli.OrderID = o.ID AND oli.ParentOrderLineItemID = 0 AND oli.IsDeleted = 0) AS ItemCount + FROM Orders o + INNER JOIN Businesses b ON b.ID = o.BusinessID + LEFT JOIN tt_OrderTypes ot ON ot.ID = o.OrderTypeID + LEFT JOIN ServicePoints sp ON sp.ID = o.ServicePointID + WHERE o.UserID = ? + AND o.StatusID = 0 +"; + +$params = [$UserID]; + +if ($BusinessID > 0) { + $sql .= " AND o.BusinessID = ?"; + $params[] = $BusinessID; +} + +$sql .= " ORDER BY o.AddedOn DESC LIMIT 1"; + +try { + $rows = queryTimed($sql, $params); + $cart = $rows[0] ?? null; + + if (!$cart) { + jsonResponse(['OK' => true, 'HAS_CART' => false, 'CART' => null]); + } + + jsonResponse(['OK' => true, 'HAS_CART' => true, 'CART' => [ + 'OrderID' => (int) $cart['OrderID'], + 'OrderUUID' => $cart['OrderUUID'], + 'BusinessID' => (int) $cart['BusinessID'], + 'BusinessName' => $cart['BusinessName'], + 'OrderTypeID' => (int) $cart['OrderTypeID'], + 'OrderTypeName' => $cart['OrderTypeName'], + 'ServicePointID' => (int) $cart['ServicePointID'], + 'ServicePointName' => $cart['ServicePointName'], + 'ItemCount' => (int) $cart['ItemCount'], + ]]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => $e->getMessage()]); +} diff --git a/api/orders/getCart.php b/api/orders/getCart.php new file mode 100644 index 0000000..7c243b0 --- /dev/null +++ b/api/orders/getCart.php @@ -0,0 +1,124 @@ + false, 'ERROR' => 'missing_orderid', 'MESSAGE' => 'OrderID is required.']); +} + +try { + $qOrder = queryOne(" + SELECT + o.ID, o.UUID, o.UserID, o.BusinessID, o.DeliveryMultiplier, + o.OrderTypeID, o.DeliveryFee, o.StatusID, o.AddressID, o.PaymentID, + o.PaymentStatus, o.Remarks, o.AddedOn, o.LastEditedOn, o.SubmittedOn, + o.ServicePointID, + sp.Name AS ServicePointName + FROM Orders o + LEFT JOIN ServicePoints sp ON sp.ID = o.ServicePointID + WHERE o.ID = ? + LIMIT 1 + ", [$OrderID]); + + if (!$qOrder) { + apiAbort(['OK' => false, 'ERROR' => 'not_found', 'MESSAGE' => 'Order not found.']); + } + + // Get business info + $qBusiness = queryOne( + "SELECT DeliveryFlatFee, OrderTypes, TaxRate, PayfritFee FROM Businesses WHERE ID = ? LIMIT 1", + [(int) $qOrder['BusinessID']] + ); + + $businessDeliveryFee = $qBusiness ? (float) $qBusiness['DeliveryFlatFee'] : 0; + $businessTaxRate = ($qBusiness && is_numeric($qBusiness['TaxRate'])) ? (float) $qBusiness['TaxRate'] : 0; + $businessPayfritFee = ($qBusiness && is_numeric($qBusiness['PayfritFee'])) ? (float) $qBusiness['PayfritFee'] : 0.05; + $businessOrderTypes = ($qBusiness && trim($qBusiness['OrderTypes'] ?? '') !== '') ? $qBusiness['OrderTypes'] : '1,2,3'; + $businessOrderTypesArray = explode(',', $businessOrderTypes); + + // Get line items + $qLI = queryTimed(" + SELECT + oli.ID, oli.ParentOrderLineItemID, oli.OrderID, oli.ItemID, + oli.StatusID, oli.Price, oli.Quantity, oli.Remark, oli.IsDeleted, oli.AddedOn, + i.Name, i.ParentItemID, i.IsCheckedByDefault, + parent.Name AS ItemParentName + FROM OrderLineItems oli + INNER JOIN Items i ON i.ID = oli.ItemID + LEFT JOIN Items parent ON parent.ID = i.ParentItemID + WHERE oli.OrderID = ? AND oli.IsDeleted = 0 + ORDER BY oli.ID + ", [$OrderID]); + + $rows = []; + $subtotal = 0; + foreach ($qLI as $r) { + $rows[] = [ + 'OrderLineItemID' => (int) $r['ID'], + 'ParentOrderLineItemID' => (int) $r['ParentOrderLineItemID'], + 'OrderID' => (int) $r['OrderID'], + 'ItemID' => (int) $r['ItemID'], + 'StatusID' => (int) $r['StatusID'], + 'Price' => (float) $r['Price'], + 'Quantity' => (int) $r['Quantity'], + 'Remark' => $r['Remark'] ?? '', + 'IsDeleted' => (int) $r['IsDeleted'], + 'AddedOn' => $r['AddedOn'], + 'Name' => $r['Name'] ?? '', + 'ParentItemID' => (int) $r['ParentItemID'], + 'ItemParentName' => $r['ItemParentName'] ?? '', + 'IsCheckedByDefault' => (int) $r['IsCheckedByDefault'], + ]; + // Subtotal from root items only + if ((int) $r['ParentOrderLineItemID'] === 0) { + $subtotal += (float) $r['Price'] * (int) $r['Quantity']; + } + } + + $taxAmount = $subtotal * $businessTaxRate; + $deliveryFee = ((int) $qOrder['OrderTypeID'] === 3) ? (float) $qOrder['DeliveryFee'] : 0; + $total = $subtotal + $taxAmount + $deliveryFee; + + jsonResponse([ + 'OK' => true, + 'ERROR' => '', + 'ORDER' => [ + 'OrderID' => (int) $qOrder['ID'], + 'UUID' => $qOrder['UUID'] ?? '', + 'UserID' => (int) $qOrder['UserID'], + 'BusinessID' => (int) $qOrder['BusinessID'], + 'DeliveryMultiplier' => (float) $qOrder['DeliveryMultiplier'], + 'OrderTypeID' => (int) $qOrder['OrderTypeID'], + 'DeliveryFee' => $deliveryFee, + 'BusinessDeliveryFee' => $businessDeliveryFee, + 'TaxRate' => $businessTaxRate, + 'PayfritFee' => $businessPayfritFee, + 'Subtotal' => $subtotal, + 'Tax' => $taxAmount, + 'Total' => $total, + 'OrderTypes' => $businessOrderTypesArray, + 'StatusID' => (int) $qOrder['StatusID'], + 'AddressID' => (int) ($qOrder['AddressID'] ?? 0), + 'PaymentID' => (int) ($qOrder['PaymentID'] ?? 0), + 'PaymentStatus' => $qOrder['PaymentStatus'] ?? '', + 'Remarks' => $qOrder['Remarks'] ?? '', + 'AddedOn' => $qOrder['AddedOn'], + 'LastEditedOn' => $qOrder['LastEditedOn'], + 'SubmittedOn' => $qOrder['SubmittedOn'], + 'ServicePointID' => (int) ($qOrder['ServicePointID'] ?? 0), + 'ServicePointName' => $qOrder['ServicePointName'] ?? '', + ], + 'ORDERLINEITEMS' => $rows, + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => 'DB error loading cart', 'DETAIL' => $e->getMessage()]); +} diff --git a/api/orders/getDetail.php b/api/orders/getDetail.php new file mode 100644 index 0000000..2200bb3 --- /dev/null +++ b/api/orders/getDetail.php @@ -0,0 +1,186 @@ + 'Cart', 1 => 'Submitted', 2 => 'In Progress', 3 => 'Ready', + 4 => 'On the Way', 5 => 'Complete', 6 => 'Cancelled', 7 => 'Deleted', + default => 'Unknown', + }; +} + +function getOrderTypeName(int $orderType): string { + return match ($orderType) { + 1 => 'Dine-in', 2 => 'Takeaway', 3 => 'Delivery', default => 'Unknown', + }; +} + +$response = ['OK' => false]; + +try { + // Get OrderID from GET or POST + $orderID = (int) ($_GET['OrderID'] ?? 0); + if ($orderID === 0) { + $data = readJsonBody(); + $orderID = (int) ($data['OrderID'] ?? 0); + } + + if ($orderID === 0) { + $response['ERROR'] = 'missing_order_id'; + $response['MESSAGE'] = 'OrderID is required'; + jsonResponse($response); + } + + $qOrder = queryOne(" + SELECT + o.ID, o.UUID, o.BusinessID, o.UserID, o.ServicePointID, + o.StatusID, o.OrderTypeID, o.Remarks, o.AddedOn, o.LastEditedOn, + o.SubmittedOn, o.TipAmount, + u.FirstName, u.LastName, u.ContactNumber, u.EmailAddress, + sp.Name AS Name, sp.TypeID AS TypeID, + b.Name AS BizName, b.TaxRate + FROM Orders o + LEFT JOIN Users u ON u.ID = o.UserID + LEFT JOIN ServicePoints sp ON sp.ID = o.ServicePointID + LEFT JOIN Businesses b ON b.ID = o.BusinessID + WHERE o.ID = ? + ", [$orderID]); + + if (!$qOrder) { + $response['ERROR'] = 'order_not_found'; + $response['MESSAGE'] = 'Order not found'; + jsonResponse($response); + } + + // Get line items + $qItems = queryTimed(" + SELECT + oli.ID, oli.ItemID, oli.ParentOrderLineItemID, + oli.Quantity, oli.Price, oli.Remark, + i.Name, i.Price AS ItemPrice, i.IsCheckedByDefault + FROM OrderLineItems oli + INNER JOIN Items i ON i.ID = oli.ItemID + WHERE oli.OrderID = ? AND oli.IsDeleted = 0 + ORDER BY oli.ID + ", [$orderID]); + + // Build hierarchy + $itemsById = []; + foreach ($qItems as $row) { + $itemsById[(int) $row['ID']] = [ + 'LineItemID' => (int) $row['ID'], + 'ItemID' => (int) $row['ItemID'], + 'ParentLineItemID' => (int) $row['ParentOrderLineItemID'], + 'Name' => $row['Name'] ?? '', + 'Quantity' => (int) $row['Quantity'], + 'UnitPrice' => (float) $row['Price'], + 'Remarks' => $row['Remark'] ?? '', + 'IsDefault' => ((int) $row['IsCheckedByDefault'] === 1), + 'Modifiers' => [], + ]; + } + + $lineItems = []; + foreach ($qItems as $row) { + $id = (int) $row['ID']; + $parentID = (int) $row['ParentOrderLineItemID']; + if ($parentID > 0 && isset($itemsById[$parentID])) { + $itemsById[$parentID]['Modifiers'][] = &$itemsById[$id]; + } else { + $lineItems[] = &$itemsById[$id]; + } + } + unset($id); // break reference + + // Calculate subtotal + $subtotal = 0; + foreach ($lineItems as $item) { + $itemTotal = $item['UnitPrice'] * $item['Quantity']; + foreach ($item['Modifiers'] as $mod) { + $itemTotal += $mod['UnitPrice'] * $mod['Quantity']; + } + $subtotal += $itemTotal; + } + + $taxRate = (is_numeric($qOrder['TaxRate']) && (float) $qOrder['TaxRate'] > 0) ? (float) $qOrder['TaxRate'] : 0; + $tax = $subtotal * $taxRate; + $tip = is_numeric($qOrder['TipAmount']) ? (float) $qOrder['TipAmount'] : 0; + $total = $subtotal + $tax + $tip; + + // Get staff who worked on this order + $qStaff = queryTimed(" + SELECT DISTINCT u.ID, u.FirstName, + (SELECT r.AccessToken + FROM TaskRatings r + INNER JOIN Tasks t2 ON t2.ID = r.TaskID + WHERE t2.OrderID = ? + AND r.ForUserID = u.ID + AND r.Direction = 'customer_rates_worker' + AND r.CompletedOn IS NULL + AND r.ExpiresOn > NOW() + LIMIT 1) AS RatingToken + FROM Tasks t + INNER JOIN Users u ON u.ID = t.ClaimedByUserID + WHERE t.OrderID = ? AND t.ClaimedByUserID > 0 + ", [$orderID, $orderID]); + + $staff = []; + foreach ($qStaff as $row) { + $staff[] = [ + 'UserID' => (int) $row['ID'], + 'FirstName' => $row['FirstName'], + 'AvatarUrl' => baseUrl() . '/uploads/users/' . $row['ID'] . '.jpg', + 'RatingToken' => $row['RatingToken'] ?? '', + ]; + } + + $statusID = (int) $qOrder['StatusID']; + $orderTypeID = (int) ($qOrder['OrderTypeID'] ?? 0); + + $response['OK'] = true; + $response['ORDER'] = [ + 'OrderID' => (int) $qOrder['ID'], + 'UUID' => $qOrder['UUID'] ?? '', + 'BusinessID' => (int) $qOrder['BusinessID'], + 'Name' => $qOrder['BizName'] ?? '', + 'Status' => $statusID, + 'StatusText' => getStatusText($statusID), + 'OrderTypeID' => $orderTypeID, + 'OrderTypeName' => getOrderTypeName($orderTypeID), + 'Subtotal' => $subtotal, + 'Tax' => $tax, + 'Tip' => $tip, + 'Total' => $total, + 'Notes' => $qOrder['Remarks'], + 'CreatedOn' => toISO8601($qOrder['AddedOn']), + 'SubmittedOn' => !empty($qOrder['SubmittedOn']) ? toISO8601($qOrder['SubmittedOn']) : '', + 'UpdatedOn' => !empty($qOrder['LastEditedOn']) ? toISO8601($qOrder['LastEditedOn']) : '', + 'Customer' => [ + 'UserID' => (int) $qOrder['UserID'], + 'FirstName' => $qOrder['FirstName'], + 'LastName' => $qOrder['LastName'], + 'Phone' => $qOrder['ContactNumber'], + 'Email' => $qOrder['EmailAddress'], + ], + 'ServicePoint' => [ + 'ServicePointID' => (int) ($qOrder['ServicePointID'] ?? 0), + 'Name' => $qOrder['Name'] ?? '', + 'TypeID' => (int) ($qOrder['TypeID'] ?? 0), + ], + 'LineItems' => $lineItems, + 'Staff' => $staff, + ]; + +} catch (Exception $e) { + $response['ERROR'] = 'server_error'; + $response['MESSAGE'] = $e->getMessage(); +} + +jsonResponse($response); diff --git a/api/orders/getOrCreateCart.php b/api/orders/getOrCreateCart.php new file mode 100644 index 0000000..a7e9d94 --- /dev/null +++ b/api/orders/getOrCreateCart.php @@ -0,0 +1,139 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'BusinessID and UserID are required.']); +} + +if ($OrderTypeID === 1 && $ServicePointID <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'missing_service_point', 'MESSAGE' => 'ServicePointID is required for dine-in. Please scan a table beacon.']); +} + +if ($OrderTypeID < 0 || $OrderTypeID > 3) { + apiAbort(['OK' => false, 'ERROR' => 'invalid_order_type', 'MESSAGE' => 'OrderTypeID must be 0-3 (0=undecided, 1=dine-in, 2=takeaway, 3=delivery).']); +} + +try { + $qBiz = queryOne("SELECT DeliveryFlatFee FROM Businesses WHERE ID = ? LIMIT 1", [$BusinessID]); + if (!$qBiz) { + apiAbort(['OK' => false, 'ERROR' => 'bad_business', 'MESSAGE' => 'Business not found']); + } + + // SP-SM: Resolve grant if ServicePoint doesn't belong to this business + $grantID = 0; + $grantOwnerBusinessID = 0; + $grantEconomicsType = ''; + $grantEconomicsValue = 0; + + if ($ServicePointID > 0) { + $qSPOwner = queryOne("SELECT BusinessID FROM ServicePoints WHERE ID = ? LIMIT 1", [$ServicePointID]); + if ($qSPOwner && (int) $qSPOwner['BusinessID'] !== $BusinessID) { + // SP belongs to another business + $qParent = queryOne("SELECT ParentBusinessID FROM Businesses WHERE ID = ? LIMIT 1", [$BusinessID]); + $isChildOfSPOwner = $qParent && (int) ($qParent['ParentBusinessID'] ?? 0) === (int) $qSPOwner['BusinessID']; + + if (!$isChildOfSPOwner) { + // Check for active grant + $qGrant = queryOne( + "SELECT ID, OwnerBusinessID, EconomicsType, EconomicsValue, EligibilityScope, TimePolicyType, TimePolicyData + FROM ServicePointGrants + WHERE GuestBusinessID = ? AND ServicePointID = ? AND StatusID = 1 + LIMIT 1", + [$BusinessID, $ServicePointID] + ); + if (!$qGrant) { + apiAbort(['OK' => false, 'ERROR' => 'sp_not_accessible', 'MESSAGE' => 'Service point is not accessible to your business.']); + } + if (!isGrantTimeActive($qGrant['TimePolicyType'], $qGrant['TimePolicyData'])) { + apiAbort(['OK' => false, 'ERROR' => 'grant_time_inactive', 'MESSAGE' => 'Service point access is not available at this time.']); + } + if (!checkGrantEligibility($qGrant['EligibilityScope'], $UserID, (int) $qGrant['OwnerBusinessID'], $BusinessID)) { + apiAbort(['OK' => false, 'ERROR' => 'grant_eligibility_failed', 'MESSAGE' => 'You are not eligible to order at this service point.']); + } + $grantID = (int) $qGrant['ID']; + $grantOwnerBusinessID = (int) $qGrant['OwnerBusinessID']; + $grantEconomicsType = $qGrant['EconomicsType']; + $grantEconomicsValue = (float) $qGrant['EconomicsValue']; + } + } + } + + // Check if user is on an active tab at this business + $tabID = 0; + $qUserTab = queryOne(" + SELECT t.ID + FROM TabMembers tm + JOIN Tabs t ON t.ID = tm.TabID + WHERE tm.UserID = ? AND tm.StatusID = 1 AND t.BusinessID = ? AND t.StatusID = 1 + LIMIT 1 + ", [$UserID, $BusinessID]); + if ($qUserTab) { + $tabID = (int) $qUserTab['ID']; + } + + $nowISO = gmdate('Y-m-d H:i:s'); + $newUUID = generateUUID(); + $deliveryFee = ($OrderTypeID === 3) ? (float) $qBiz['DeliveryFlatFee'] : 0; + + // Generate new OrderID (table is not auto-inc) + $qNext = queryOne("SELECT IFNULL(MAX(ID),0) + 1 AS NextID FROM Orders", []); + $NewOrderID = (int) $qNext['NextID']; + + queryTimed(" + INSERT INTO Orders ( + ID, UUID, UserID, BusinessID, DeliveryMultiplier, OrderTypeID, DeliveryFee, + StatusID, AddressID, PaymentID, Remarks, AddedOn, LastEditedOn, SubmittedOn, + ServicePointID, GrantID, GrantOwnerBusinessID, GrantEconomicsType, + GrantEconomicsValue, TabID + ) VALUES ( + ?, ?, ?, ?, 1.0, ?, ?, + 0, NULL, NULL, NULL, ?, ?, NULL, + ?, ?, ?, ?, ?, ? + ) + ", [ + $NewOrderID, + $newUUID, + $UserID, + $BusinessID, + $OrderTypeID, + $deliveryFee, + $nowISO, + $nowISO, + $ServicePointID, + $grantID > 0 ? $grantID : null, + $grantOwnerBusinessID > 0 ? $grantOwnerBusinessID : null, + strlen($grantEconomicsType) > 0 ? $grantEconomicsType : null, + ($grantEconomicsType !== '' && $grantEconomicsType !== 'none') ? $grantEconomicsValue : null, + $tabID > 0 ? $tabID : null, + ]); + + // Get the final ID + $qLatest = queryOne("SELECT MAX(ID) AS NextID FROM Orders", []); + $FinalOrderID = (int) $qLatest['NextID']; + + $payload = loadCartPayload($FinalOrderID); + jsonResponse($payload); + +} catch (Exception $e) { + jsonResponse([ + 'OK' => false, + 'ERROR' => 'server_error: ' . $e->getMessage(), + 'MESSAGE' => 'DB error creating cart', + 'DETAIL' => $e->getMessage(), + ]); +} diff --git a/api/orders/getPendingForUser.php b/api/orders/getPendingForUser.php new file mode 100644 index 0000000..fe6d6d4 --- /dev/null +++ b/api/orders/getPendingForUser.php @@ -0,0 +1,78 @@ + false]; + +try { + $UserID = (int) ($_GET['UserID'] ?? 0); + $BusinessID = (int) ($_GET['BusinessID'] ?? 0); + + if ($UserID <= 0) { + $response['ERROR'] = 'missing_user'; + $response['MESSAGE'] = 'UserID is required'; + jsonResponse($response); + } + + if ($BusinessID <= 0) { + $response['ERROR'] = 'missing_business'; + $response['MESSAGE'] = 'BusinessID is required'; + jsonResponse($response); + } + + $qOrders = queryTimed(" + SELECT + o.ID, o.UUID, o.OrderTypeID, o.StatusID, o.SubmittedOn, + o.ServicePointID, + sp.Name AS Name, + b.Name AS BizName, + (SELECT COALESCE(SUM(oli.Price * oli.Quantity), 0) + FROM OrderLineItems oli + WHERE oli.OrderID = o.ID AND oli.IsDeleted = 0 AND oli.ParentOrderLineItemID = 0) AS Subtotal + FROM Orders o + LEFT JOIN ServicePoints sp ON sp.ID = o.ServicePointID + LEFT JOIN Businesses b ON b.ID = o.BusinessID + WHERE o.UserID = ? AND o.BusinessID = ? AND o.StatusID IN (1, 2, 3) + ORDER BY o.SubmittedOn DESC + LIMIT 5 + ", [$UserID, $BusinessID]); + + $orders = []; + foreach ($qOrders as $row) { + $statusName = match ((int) $row['StatusID']) { + 1 => 'Submitted', 2 => 'Preparing', 3 => 'Ready for Pickup', default => '', + }; + $orderTypeName = match ((int) $row['OrderTypeID']) { + 1 => 'Dine-In', 2 => 'Takeaway', 3 => 'Delivery', default => '', + }; + + $orders[] = [ + 'OrderID' => (int) $row['ID'], + 'UUID' => $row['UUID'], + 'OrderTypeID' => (int) $row['OrderTypeID'], + 'OrderTypeName' => $orderTypeName, + 'StatusID' => (int) $row['StatusID'], + 'StatusName' => $statusName, + 'SubmittedOn' => toISO8601($row['SubmittedOn']), + 'ServicePointID' => (int) ($row['ServicePointID'] ?? 0), + 'Name' => trim($row['Name'] ?? '') !== '' ? $row['Name'] : '', + 'Subtotal' => (float) $row['Subtotal'], + ]; + } + + $response['OK'] = true; + $response['ORDERS'] = $orders; + $response['HAS_PENDING'] = count($orders) > 0; + +} catch (Exception $e) { + $response['ERROR'] = 'server_error'; + $response['MESSAGE'] = $e->getMessage(); +} + +jsonResponse($response); diff --git a/api/orders/history.php b/api/orders/history.php new file mode 100644 index 0000000..0c96913 --- /dev/null +++ b/api/orders/history.php @@ -0,0 +1,106 @@ + false, 'ERROR' => 'not_logged_in', 'MESSAGE' => 'Authentication required']); +} + +$limit = (int) ($_GET['limit'] ?? 20); +$offset = (int) ($_GET['offset'] ?? 0); +if ($limit < 1) $limit = 20; +if ($limit > 100) $limit = 100; +if ($offset < 0) $offset = 0; + +try { + $qOrders = queryTimed(" + SELECT + o.ID, o.UUID, o.BusinessID, o.StatusID, o.OrderTypeID, + o.AddedOn, o.LastEditedOn, + b.Name AS BusinessName, + CASE o.OrderTypeID + WHEN 0 THEN 'Undecided' + WHEN 1 THEN 'Dine-In' + WHEN 2 THEN 'Takeaway' + WHEN 3 THEN 'Delivery' + ELSE 'Unknown' + END AS OrderTypeName + FROM Orders o + LEFT JOIN Businesses b ON b.ID = o.BusinessID + WHERE o.UserID = ? AND o.StatusID > 0 + ORDER BY o.AddedOn DESC + LIMIT ? OFFSET ? + ", [$userId, $limit, $offset]); + + $qCount = queryOne(" + SELECT COUNT(*) AS TotalCount + FROM Orders + WHERE UserID = ? AND StatusID > 0 + ", [$userId]); + + $orders = []; + foreach ($qOrders as $row) { + $qItems = queryOne(" + SELECT COUNT(*) AS ItemCount, SUM(Quantity * Price) AS Subtotal + FROM OrderLineItems + WHERE OrderID = ? AND ParentOrderLineItemID = 0 AND (IsDeleted = 0 OR IsDeleted IS NULL) + ", [(int) $row['ID']]); + + $itemCount = (int) ($qItems['ItemCount'] ?? 0); + $subtotal = (float) ($qItems['Subtotal'] ?? 0); + $tax = $subtotal * 0.0875; + $total = $subtotal + $tax; + + $statusText = match ((int) $row['StatusID']) { + 1 => 'Submitted', 2 => 'In Progress', 3 => 'Ready', + 4 => 'Completed', 5 => 'Cancelled', default => 'Unknown', + }; + + $createdAt = ''; + if (!empty($row['AddedOn'])) { + $createdAt = toISO8601($row['AddedOn']); + } + + $completedAt = ''; + if ((int) $row['StatusID'] >= 4 && !empty($row['LastEditedOn'])) { + $completedAt = toISO8601($row['LastEditedOn']); + } + + $orders[] = [ + 'OrderID' => (int) $row['ID'], + 'OrderUUID' => $row['UUID'] ?? '', + 'BusinessID' => (int) $row['BusinessID'], + 'BusinessName' => $row['BusinessName'] ?? 'Unknown', + 'OrderTotal' => round($total * 100) / 100, + 'OrderStatusID' => (int) $row['StatusID'], + 'StatusName' => $statusText, + 'OrderTypeID' => (int) $row['OrderTypeID'], + 'TypeName' => $row['OrderTypeName'] ?? 'Unknown', + 'ItemCount' => $itemCount, + 'CreatedAt' => $createdAt, + 'CompletedAt' => $completedAt, + ]; + } + + jsonResponse([ + 'OK' => true, + 'ORDERS' => $orders, + 'TOTAL_COUNT' => (int) ($qCount['TotalCount'] ?? 0), + ]); + +} catch (Exception $e) { + jsonResponse([ + 'OK' => false, + 'ERROR' => 'server_error', + 'MESSAGE' => 'Failed to load order history', + 'DETAIL' => $e->getMessage(), + ]); +} diff --git a/api/orders/listForKDS.php b/api/orders/listForKDS.php new file mode 100644 index 0000000..e312a32 --- /dev/null +++ b/api/orders/listForKDS.php @@ -0,0 +1,132 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'BusinessID is required.']); +} + +try { + // Build WHERE clause + $whereClauses = ['o.BusinessID = ?']; + $params = [$BusinessID]; + + if ($ServicePointID > 0) { + $whereClauses[] = 'o.ServicePointID = ?'; + $params[] = $ServicePointID; + } + + $whereClauses[] = 'o.StatusID >= 1'; + $whereClauses[] = 'o.StatusID < 4'; + + $whereSQL = implode(' AND ', $whereClauses); + + if ($StationID > 0) { + $stationParams = array_merge($params, [$StationID]); + $qOrders = queryTimed(" + SELECT DISTINCT + o.ID, o.UUID, o.UserID, o.BusinessID, o.OrderTypeID, o.StatusID, + o.ServicePointID, o.Remarks, + DATE_FORMAT(o.SubmittedOn, '%Y-%m-%dT%H:%i:%sZ') AS SubmittedOn, + DATE_FORMAT(o.LastEditedOn, '%Y-%m-%dT%H:%i:%sZ') AS LastEditedOn, + sp.Name AS Name, + u.FirstName, u.LastName + FROM Orders o + LEFT JOIN ServicePoints sp ON sp.ID = o.ServicePointID + LEFT JOIN Users u ON u.ID = o.UserID + INNER JOIN OrderLineItems oli ON oli.OrderID = o.ID + INNER JOIN Items i ON i.ID = oli.ItemID + WHERE {$whereSQL} + AND (i.StationID = ? OR i.StationID IS NULL) + AND oli.IsDeleted = 0 + ORDER BY SubmittedOn ASC, o.ID ASC + ", $stationParams); + } else { + $qOrders = queryTimed(" + SELECT + o.ID, o.UUID, o.UserID, o.BusinessID, o.OrderTypeID, o.StatusID, + o.ServicePointID, o.Remarks, + DATE_FORMAT(o.SubmittedOn, '%Y-%m-%dT%H:%i:%sZ') AS SubmittedOn, + DATE_FORMAT(o.LastEditedOn, '%Y-%m-%dT%H:%i:%sZ') AS LastEditedOn, + sp.Name AS Name, + u.FirstName, u.LastName + FROM Orders o + LEFT JOIN ServicePoints sp ON sp.ID = o.ServicePointID + LEFT JOIN Users u ON u.ID = o.UserID + WHERE {$whereSQL} + ORDER BY o.SubmittedOn ASC, o.ID ASC + ", $params); + } + + $orders = []; + foreach ($qOrders as $row) { + // Get line items for this order + $qLineItems = queryTimed(" + SELECT + oli.ID, oli.ParentOrderLineItemID, oli.ItemID, oli.Price, + oli.Quantity, oli.Remark, oli.IsDeleted, oli.StatusID, + i.Name, i.ParentItemID, i.IsCheckedByDefault, i.StationID, + parent.Name AS ItemParentName + FROM OrderLineItems oli + INNER JOIN Items i ON i.ID = oli.ItemID + LEFT JOIN Items parent ON parent.ID = i.ParentItemID + WHERE oli.OrderID = ? AND oli.IsDeleted = 0 + ORDER BY oli.ID + ", [(int) $row['ID']]); + + $lineItems = []; + foreach ($qLineItems as $li) { + $lineItems[] = [ + 'OrderLineItemID' => (int) $li['ID'], + 'ParentOrderLineItemID' => (int) $li['ParentOrderLineItemID'], + 'ItemID' => (int) $li['ItemID'], + 'Price' => (float) $li['Price'], + 'Quantity' => (int) $li['Quantity'], + 'Remark' => $li['Remark'], + 'Name' => $li['Name'], + 'ParentItemID' => (int) $li['ParentItemID'], + 'ItemParentName' => $li['ItemParentName'], + 'IsCheckedByDefault' => (int) $li['IsCheckedByDefault'], + 'StationID' => (int) ($li['StationID'] ?? 0), + 'StatusID' => (int) $li['StatusID'], + ]; + } + + $orderTypeName = match ((int) $row['OrderTypeID']) { + 1 => 'Dine-In', 2 => 'Takeaway', 3 => 'Delivery', default => '', + }; + + $orders[] = [ + 'OrderID' => (int) $row['ID'], + 'UUID' => $row['UUID'], + 'UserID' => (int) $row['UserID'], + 'BusinessID' => (int) $row['BusinessID'], + 'OrderTypeID' => (int) $row['OrderTypeID'], + 'OrderTypeName' => $orderTypeName, + 'StatusID' => (int) $row['StatusID'], + 'ServicePointID' => (int) ($row['ServicePointID'] ?? 0), + 'Remarks' => $row['Remarks'], + 'SubmittedOn' => $row['SubmittedOn'], + 'LastEditedOn' => $row['LastEditedOn'], + 'Name' => $row['Name'], + 'FirstName' => $row['FirstName'], + 'LastName' => $row['LastName'], + 'LineItems' => $lineItems, + ]; + } + + jsonResponse(['OK' => true, 'ERROR' => '', 'ORDERS' => $orders, 'STATION_FILTER' => $StationID]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => 'DB error loading orders for KDS', 'DETAIL' => $e->getMessage()]); +} diff --git a/api/orders/markStationDone.php b/api/orders/markStationDone.php new file mode 100644 index 0000000..66a7204 --- /dev/null +++ b/api/orders/markStationDone.php @@ -0,0 +1,98 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'OrderID and StationID are required.']); +} + +try { + $qOrder = queryOne(" + SELECT o.ID, o.StatusID, o.BusinessID, o.ServicePointID, sp.Name + FROM Orders o + LEFT JOIN ServicePoints sp ON sp.ID = o.ServicePointID + WHERE o.ID = ? LIMIT 1 + ", [$OrderID]); + + if (!$qOrder) { + apiAbort(['OK' => false, 'ERROR' => 'not_found', 'MESSAGE' => 'Order not found.']); + } + + $oldStatusID = (int) $qOrder['StatusID']; + + // Mark root line items for this station as done (StatusID = 1) + queryTimed(" + UPDATE OrderLineItems oli + INNER JOIN Items i ON i.ID = oli.ItemID + SET oli.StatusID = 1 + WHERE oli.OrderID = ? AND i.StationID = ? AND oli.ParentOrderLineItemID = 0 AND oli.IsDeleted = 0 + ", [$OrderID, $StationID]); + + // Also mark children (modifiers) of those root items as done + queryTimed(" + UPDATE OrderLineItems oli + SET oli.StatusID = 1 + WHERE oli.OrderID = ? AND oli.IsDeleted = 0 + AND oli.ParentOrderLineItemID IN ( + SELECT sub.ID FROM ( + SELECT oli2.ID + FROM OrderLineItems oli2 + INNER JOIN Items i2 ON i2.ID = oli2.ItemID + WHERE oli2.OrderID = ? AND i2.StationID = ? + AND oli2.ParentOrderLineItemID = 0 AND oli2.IsDeleted = 0 + ) sub + ) + ", [$OrderID, $OrderID, $StationID]); + + // Auto-promote to status 2 if was at status 1 + if ($oldStatusID === 1) { + queryTimed("UPDATE Orders SET StatusID = 2, LastEditedOn = NOW() WHERE ID = ?", [$OrderID]); + $oldStatusID = 2; + } + + // Check if ALL station-assigned root items are now done + $qRemaining = queryOne(" + SELECT COUNT(*) AS RemainingCount + FROM OrderLineItems oli + INNER JOIN Items i ON i.ID = oli.ItemID + WHERE oli.OrderID = ? AND oli.ParentOrderLineItemID = 0 AND oli.IsDeleted = 0 + AND i.StationID > 0 AND oli.StatusID = 0 + ", [$OrderID]); + + $allStationsDone = ((int) ($qRemaining['RemainingCount'] ?? 0) === 0); + $NewStatusID = $oldStatusID; + $taskCreated = false; + + // Auto-promote to status 3 and create tasks + if ($allStationsDone && $oldStatusID < 3) { + $NewStatusID = 3; + queryTimed("UPDATE Orders SET StatusID = 3, LastEditedOn = NOW() WHERE ID = ?", [$OrderID]); + + require __DIR__ . '/_createOrderTasks.php'; + } + + jsonResponse([ + 'OK' => true, + 'ERROR' => '', + 'MESSAGE' => 'Station items marked as done.', + 'OrderID' => $OrderID, + 'StationID' => $StationID, + 'StationDone' => true, + 'AllStationsDone' => $allStationsDone, + 'NewOrderStatusID' => $NewStatusID, + 'TaskCreated' => $allStationsDone && $taskCreated, + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => 'Error marking station done', 'DETAIL' => $e->getMessage()]); +} diff --git a/api/orders/setLineItem.php b/api/orders/setLineItem.php new file mode 100644 index 0000000..50f7709 --- /dev/null +++ b/api/orders/setLineItem.php @@ -0,0 +1,198 @@ + 100000) { + $WasDecoded = true; + $ItemID = $ItemID % 100000; +} + +$IsSelected = false; +if (isset($data['IsSelected'])) { + $v = $data['IsSelected']; + $IsSelected = ($v === true || $v === 1 || (is_string($v) && strtolower($v) === 'true')); +} +$Quantity = (int) ($data['Quantity'] ?? 0); +$Remark = (string) ($data['Remark'] ?? ''); +$ForceNew = false; +if (isset($data['ForceNew'])) { + $v = $data['ForceNew']; + $ForceNew = ($v === true || $v === 1 || (is_string($v) && strtolower($v) === 'true')); +} + +if ($OrderID <= 0 || $ItemID <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'missing_params', 'MESSAGE' => 'OrderID and ItemID are required.']); +} + +try { + // Load item + $qItem = queryOne("SELECT ID, Price, ParentItemID, IsActive FROM Items WHERE ID = ? LIMIT 1", [$ItemID]); + if (!$qItem || (int) $qItem['IsActive'] !== 1) { + apiAbort(['OK' => false, 'ERROR' => 'bad_item', 'MESSAGE' => "Item not found or inactive. Original={$OriginalItemID} Decoded={$ItemID} WasDecoded=" . ($WasDecoded ? 'true' : 'false')]); + } + + // Root vs modifier rules + if ($ParentLineItemID === 0) { + if ($IsSelected && $Quantity <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'bad_quantity', 'MESSAGE' => 'Root line items require Quantity > 0.']); + } + } else { + $Quantity = $IsSelected ? 1 : 0; + + // Exclusive selection group handling + if ($IsSelected) { + $qParentLI = queryOne("SELECT ItemID FROM OrderLineItems WHERE ID = ? LIMIT 1", [$ParentLineItemID]); + if ($qParentLI) { + $qParentItem = queryOne("SELECT MaxNumSelectionReq FROM Items WHERE ID = ? LIMIT 1", [(int) $qParentLI['ItemID']]); + if ($qParentItem && (int) $qParentItem['MaxNumSelectionReq'] === 1) { + queryTimed( + "UPDATE OrderLineItems SET IsDeleted = 1 WHERE OrderID = ? AND ParentOrderLineItemID = ? AND ItemID != ? AND IsDeleted = 0", + [$OrderID, $ParentLineItemID, $ItemID] + ); + } + } + } + } + + // Find existing line item + if ($ForceNew) { + $qExisting = null; + } elseif ($OrderLineItemID > 0) { + $qExisting = queryOne( + "SELECT ID FROM OrderLineItems WHERE ID = ? AND OrderID = ? AND IsDeleted = 0 LIMIT 1", + [$OrderLineItemID, $OrderID] + ); + } else { + $qExisting = queryOne( + "SELECT ID FROM OrderLineItems WHERE OrderID = ? AND ParentOrderLineItemID = ? AND ItemID = ? AND IsDeleted = 0 LIMIT 1", + [$OrderID, $ParentLineItemID, $ItemID] + ); + } + + if ($qExisting) { + // Update existing + if ($IsSelected) { + queryTimed( + "UPDATE OrderLineItems SET IsDeleted = 0, Quantity = ?, Price = ?, Remark = ?, StatusID = 0 WHERE ID = ?", + [$Quantity, (float) $qItem['Price'], trim($Remark) !== '' ? $Remark : null, (int) $qExisting['ID']] + ); + attachDefaultChildren($OrderID, (int) $qExisting['ID'], $ItemID); + } else { + // Deselecting + if ($ParentLineItemID > 0) { + $qItemCheck = queryOne("SELECT IsCheckedByDefault FROM Items WHERE ID = ? LIMIT 1", [$ItemID]); + if ($qItemCheck && (int) $qItemCheck['IsCheckedByDefault'] === 1) { + // Default modifier: keep with Quantity=0 + queryTimed("UPDATE OrderLineItems SET Quantity = 0 WHERE ID = ?", [(int) $qExisting['ID']]); + } else { + queryTimed("UPDATE OrderLineItems SET IsDeleted = 1 WHERE ID = ?", [(int) $qExisting['ID']]); + } + } else { + // Root item: always delete + queryTimed("UPDATE OrderLineItems SET IsDeleted = 1 WHERE ID = ?", [(int) $qExisting['ID']]); + } + } + } else { + // Insert new if selecting + if ($IsSelected) { + $NewLIID = nextId('OrderLineItems', 'ID'); + queryTimed( + "INSERT INTO OrderLineItems (ID, ParentOrderLineItemID, OrderID, ItemID, StatusID, Price, Quantity, Remark, IsDeleted, AddedOn) + VALUES (?, ?, ?, ?, 0, ?, ?, ?, 0, NOW())", + [ + $NewLIID, + $ParentLineItemID, + $OrderID, + $ItemID, + (float) $qItem['Price'], + ($ParentLineItemID === 0 ? $Quantity : 1), + trim($Remark) !== '' ? $Remark : null, + ] + ); + attachDefaultChildren($OrderID, $NewLIID, $ItemID); + } + } + + // Touch order last edited + queryTimed("UPDATE Orders SET LastEditedOn = NOW() WHERE ID = ?", [$OrderID]); + + $payload = loadCartPayload($OrderID); + jsonResponse($payload); + +} catch (Exception $e) { + jsonResponse([ + 'OK' => false, + 'ERROR' => 'server_error', + 'MESSAGE' => 'DB error setting line item: ' . $e->getMessage(), + ]); +} diff --git a/api/orders/setOrderType.php b/api/orders/setOrderType.php new file mode 100644 index 0000000..430b25b --- /dev/null +++ b/api/orders/setOrderType.php @@ -0,0 +1,61 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'OrderID is required.']); +} + +if ($OrderTypeID < 0 || $OrderTypeID > 3) { + apiAbort(['OK' => false, 'ERROR' => 'invalid_order_type', 'MESSAGE' => 'OrderTypeID must be 0 (undecided), 1 (dine-in), 2 (takeaway), or 3 (delivery).']); +} + +if ($OrderTypeID === 3 && $AddressID <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'missing_address', 'MESSAGE' => 'Delivery orders require an AddressID.']); +} + +try { + $qOrder = queryOne("SELECT ID, BusinessID, StatusID FROM Orders WHERE ID = ? LIMIT 1", [$OrderID]); + + if (!$qOrder) { + apiAbort(['OK' => false, 'ERROR' => 'not_found', 'MESSAGE' => 'Order not found.']); + } + + if ((int) $qOrder['StatusID'] !== 0) { + apiAbort(['OK' => false, 'ERROR' => 'invalid_status', 'MESSAGE' => 'Order type can only be changed while in cart status.']); + } + + if ($OrderTypeID === 3) { + // Delivery: set address and fee + $qBiz = queryOne("SELECT DeliveryFlatFee FROM Businesses WHERE ID = ? LIMIT 1", [(int) $qOrder['BusinessID']]); + $deliveryFee = $qBiz ? (float) $qBiz['DeliveryFlatFee'] : 0; + + queryTimed( + "UPDATE Orders SET OrderTypeID = ?, AddressID = ?, DeliveryFee = ?, LastEditedOn = NOW() WHERE ID = ?", + [$OrderTypeID, $AddressID, $deliveryFee, $OrderID] + ); + } else { + // Takeaway/Dine-in: clear address and fee + queryTimed( + "UPDATE Orders SET OrderTypeID = ?, AddressID = NULL, DeliveryFee = 0, LastEditedOn = NOW() WHERE ID = ?", + [$OrderTypeID, $OrderID] + ); + } + + $payload = loadCartPayload($OrderID); + jsonResponse($payload); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => 'DB error updating order type', 'DETAIL' => $e->getMessage()]); +} diff --git a/api/orders/submit.php b/api/orders/submit.php new file mode 100644 index 0000000..a24e5a3 --- /dev/null +++ b/api/orders/submit.php @@ -0,0 +1,203 @@ + [], 'children' => [], 'itemMeta' => []]; + + $qLI = queryTimed( + "SELECT ID, ParentOrderLineItemID, ItemID, IsDeleted FROM OrderLineItems WHERE OrderID = ? ORDER BY ID", + [$OrderID] + ); + + if (empty($qLI)) return $out; + + $itemIds = []; + foreach ($qLI as $row) { + $id = (int) $row['ID']; + $parentId = (int) $row['ParentOrderLineItemID']; + $out['items'][$id] = [ + 'id' => $id, + 'parentId' => $parentId, + 'itemId' => (int) $row['ItemID'], + 'isDeleted' => (bool) $row['IsDeleted'], + ]; + $out['children'][$parentId][] = $id; + $itemIds[] = (int) $row['ItemID']; + } + + $uniq = array_unique($itemIds); + if (!empty($uniq)) { + $placeholders = implode(',', array_fill(0, count($uniq), '?')); + $qMeta = queryTimed( + "SELECT ID, RequiresChildSelection, MaxNumSelectionReq FROM Items WHERE ID IN ({$placeholders})", + array_values($uniq) + ); + foreach ($qMeta as $row) { + $out['itemMeta'][(int) $row['ID']] = [ + 'requires' => (int) $row['RequiresChildSelection'], + 'maxSel' => (int) $row['MaxNumSelectionReq'], + ]; + } + } + + return $out; +} + +function hasSelectedDescendant(array $graph, int $lineItemId): bool { + $stack = $graph['children'][$lineItemId] ?? []; + + while (!empty($stack)) { + $id = array_pop($stack); + if (isset($graph['items'][$id])) { + $node = $graph['items'][$id]; + if (!$node['isDeleted']) return true; + foreach ($graph['children'][$id] ?? [] as $kidId) { + $stack[] = $kidId; + } + } + } + + return false; +} + +// --- Main logic --- + +$data = readJsonBody(); +$OrderID = (int) ($data['OrderID'] ?? 0); + +if ($OrderID <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'missing_orderid', 'MESSAGE' => 'OrderID is required.']); +} + +try { + $qOrder = queryOne( + "SELECT ID, UserID, StatusID, OrderTypeID, BusinessID, ServicePointID, GrantID, GrantOwnerBusinessID + FROM Orders WHERE ID = ? LIMIT 1", + [$OrderID] + ); + + if (!$qOrder) { + apiAbort(['OK' => false, 'ERROR' => 'not_found', 'MESSAGE' => 'Order not found.']); + } + + if ((int) $qOrder['StatusID'] !== 0) { + apiAbort(['OK' => false, 'ERROR' => 'bad_state', 'MESSAGE' => 'Order is not in cart state.']); + } + + $orderTypeID = (int) $qOrder['OrderTypeID']; + if ($orderTypeID < 1 || $orderTypeID > 3) { + apiAbort(['OK' => false, 'ERROR' => 'bad_type', 'MESSAGE' => 'Order type must be set before submitting (1=dine-in, 2=takeaway, 3=delivery).']); + } + + // Delivery requires address + if ($orderTypeID === 3) { + $qAddr = queryOne("SELECT AddressID FROM Orders WHERE ID = ? LIMIT 1", [$OrderID]); + if (!$qAddr || (int) ($qAddr['AddressID'] ?? 0) <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'missing_address', 'MESSAGE' => 'Delivery orders require a delivery address.']); + } + } + + // Re-validate grant + $grantID = (int) ($qOrder['GrantID'] ?? 0); + if ($grantID > 0) { + $qGrantCheck = queryOne( + "SELECT StatusID, TimePolicyType, TimePolicyData FROM ServicePointGrants WHERE ID = ? LIMIT 1", + [$grantID] + ); + if (!$qGrantCheck || (int) $qGrantCheck['StatusID'] !== 1) { + apiAbort(['OK' => false, 'ERROR' => 'grant_revoked', 'MESSAGE' => 'Access to this service point has been revoked.']); + } + if (!isGrantTimeActive($qGrantCheck['TimePolicyType'], $qGrantCheck['TimePolicyData'])) { + apiAbort(['OK' => false, 'ERROR' => 'grant_time_expired', 'MESSAGE' => 'Service point access is no longer available at this time.']); + } + } + + // Must have at least one root item + $qRoots = queryOne( + "SELECT COUNT(*) AS Cnt FROM OrderLineItems WHERE OrderID = ? AND ParentOrderLineItemID = 0 AND IsDeleted = 0", + [$OrderID] + ); + if ((int) ($qRoots['Cnt'] ?? 0) <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'empty_order', 'MESSAGE' => 'Order has no items.']); + } + + // Validate modifier selections + $graph = buildLineItemsGraph($OrderID); + foreach ($graph['items'] as $node) { + if ($node['isDeleted']) continue; + + $meta = $graph['itemMeta'][$node['itemId']] ?? ['requires' => 0, 'maxSel' => 0]; + + // Max immediate selections + if ($meta['maxSel'] > 0) { + $selCount = 0; + foreach ($graph['children'][$node['id']] ?? [] as $kidId) { + if (isset($graph['items'][$kidId]) && !$graph['items'][$kidId]['isDeleted']) { + $selCount++; + } + } + if ($selCount > $meta['maxSel']) { + apiAbort([ + 'OK' => false, 'ERROR' => 'max_selection_exceeded', + 'MESSAGE' => 'Too many selections under a modifier group.', + 'DETAIL' => "LineItemID {$node['id']} has {$selCount} immediate children selected; max is {$meta['maxSel']}.", + ]); + } + } + + // Requires descendant + if ($meta['requires'] === 1 && !hasSelectedDescendant($graph, $node['id'])) { + apiAbort([ + 'OK' => false, 'ERROR' => 'required_selection_missing', + 'MESSAGE' => 'A required modifier selection is missing.', + 'DETAIL' => "LineItemID {$node['id']} requires at least one descendant selection.", + ]); + } + } + + // Tab-aware submit + $tabID = 0; + $qOrderTab = queryOne("SELECT TabID FROM Orders WHERE ID = ? LIMIT 1", [$OrderID]); + if ((int) ($qOrderTab['TabID'] ?? 0) > 0) { + $tabID = (int) $qOrderTab['TabID']; + + $qTabCheck = queryOne("SELECT StatusID, OwnerUserID FROM Tabs WHERE ID = ? AND StatusID = 1 LIMIT 1", [$tabID]); + if (!$qTabCheck) { + apiAbort(['OK' => false, 'ERROR' => 'tab_not_open', 'MESSAGE' => 'The tab associated with this order is no longer open.']); + } + + if ((int) $qOrder['UserID'] !== (int) $qTabCheck['OwnerUserID']) { + $qApproval = queryOne( + "SELECT ApprovalStatus FROM TabOrders WHERE TabID = ? AND OrderID = ? LIMIT 1", + [$tabID, $OrderID] + ); + if (!$qApproval || $qApproval['ApprovalStatus'] !== 'approved') { + apiAbort(['OK' => false, 'ERROR' => 'not_approved', 'MESSAGE' => 'This order needs tab owner approval before submitting.']); + } + } + } + + // Submit + queryTimed( + "UPDATE Orders SET StatusID = 1, SubmittedOn = NOW(), LastEditedOn = NOW() WHERE ID = ?", + [$OrderID] + ); + + // Tab running total update + if ($tabID > 0) { + queryTimed("UPDATE Tabs SET LastActivityOn = NOW() WHERE ID = ?", [$tabID]); + } + + jsonResponse(['OK' => true, 'ERROR' => '', 'OrderID' => $OrderID, 'MESSAGE' => 'submitted', 'TAB_ID' => $tabID]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => 'DB error submitting order', 'DETAIL' => $e->getMessage()]); +} diff --git a/api/orders/submitCash.php b/api/orders/submitCash.php new file mode 100644 index 0000000..c541b72 --- /dev/null +++ b/api/orders/submitCash.php @@ -0,0 +1,119 @@ + false, 'ERROR' => 'missing_orderid', 'MESSAGE' => 'OrderID is required.']); +} + +if ($CashAmount <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'missing_amount', 'MESSAGE' => 'CashAmount is required.']); +} + +try { + $qOrder = queryOne(" + SELECT o.ID, o.StatusID, o.UserID, o.BusinessID, o.ServicePointID, o.PaymentID, + o.DeliveryFee, o.OrderTypeID, + b.PayfritFee, b.TaxRate + FROM Orders o + INNER JOIN Businesses b ON b.ID = o.BusinessID + WHERE o.ID = ? LIMIT 1 + ", [$OrderID]); + + if (!$qOrder) { + apiAbort(['OK' => false, 'ERROR' => 'not_found', 'MESSAGE' => 'Order not found.']); + } + + if ((int) $qOrder['StatusID'] !== 0) { + apiAbort(['OK' => false, 'ERROR' => 'bad_state', 'MESSAGE' => 'Order is not in cart state.']); + } + + // Calculate platform fee + $feeRate = (is_numeric($qOrder['PayfritFee']) && (float) $qOrder['PayfritFee'] > 0) ? (float) $qOrder['PayfritFee'] : 0; + + $qSubtotal = queryOne( + "SELECT COALESCE(SUM(Price * Quantity), 0) AS Subtotal FROM OrderLineItems WHERE OrderID = ? AND IsDeleted = 0", + [$OrderID] + ); + $subtotal = (float) ($qSubtotal['Subtotal'] ?? 0); + $platformFee = $subtotal * $feeRate; + + // Calculate full order total + $taxRate = (is_numeric($qOrder['TaxRate']) && (float) $qOrder['TaxRate'] > 0) ? (float) $qOrder['TaxRate'] : 0; + $taxAmount = $subtotal * $taxRate; + $deliveryFee = ((int) $qOrder['OrderTypeID'] === 3) ? (float) $qOrder['DeliveryFee'] : 0; + $orderTotal = round(($subtotal + $taxAmount + $platformFee + $Tip + $deliveryFee) * 100) / 100; + + // Auto-apply user balance + $balanceApplied = 0; + $cashNeeded = $orderTotal; + $userID = (int) $qOrder['UserID']; + + if ($userID > 0) { + $qBalance = queryOne("SELECT Balance FROM Users WHERE ID = ?", [$userID]); + $userBalance = (float) ($qBalance['Balance'] ?? 0); + if ($userBalance > 0) { + $balanceToApply = round(min($userBalance, $orderTotal) * 100) / 100; + // Atomic deduct + $stmt = queryTimed( + "UPDATE Users SET Balance = Balance - ? WHERE ID = ? AND Balance >= ?", + [$balanceToApply, $userID, $balanceToApply] + ); + // queryTimed returns PDOStatement for UPDATE; check rowCount + if ($stmt instanceof \PDOStatement && $stmt->rowCount() > 0) { + $balanceApplied = $balanceToApply; + $cashNeeded = max(0, $orderTotal - $balanceApplied); + } + } + } + + // Create Payment record + $CashAmountCents = (int) round($cashNeeded * 100); + queryTimed( + "INSERT INTO Payments (PaymentPaidInCash, PaymentFromPayfritBalance, PaymentFromCreditCard, PaymentSentByUserID, PaymentReceivedByUserID, PaymentOrderID, PaymentPayfritsCut, PaymentAddedOn) + VALUES (?, ?, 0, ?, 0, ?, ?, NOW())", + [$cashNeeded, $balanceApplied, $userID, $OrderID, $platformFee] + ); + $PaymentID = lastInsertId(); + + // Update order + $fullyPaidByBalance = ($cashNeeded <= 0); + $paymentStatus = $fullyPaidByBalance ? 'paid' : 'pending'; + + queryTimed(" + UPDATE Orders + SET StatusID = 1, PaymentID = ?, PaymentStatus = ?, PlatformFee = ?, + TipAmount = ?, BalanceApplied = ?, SubmittedOn = NOW(), LastEditedOn = NOW(), + PaymentCompletedOn = CASE WHEN ? = 1 THEN NOW() ELSE PaymentCompletedOn END + WHERE ID = ? + ", [$PaymentID, $paymentStatus, $platformFee, $Tip, $balanceApplied, $fullyPaidByBalance ? 1 : 0, $OrderID]); + + $response = [ + 'OK' => true, + 'OrderID' => $OrderID, + 'PaymentID' => (int) $PaymentID, + 'CashAmountCents' => $CashAmountCents, + 'MESSAGE' => 'Order submitted with cash payment.', + ]; + + if ($balanceApplied > 0) { + $response['BalanceApplied'] = round($balanceApplied * 100) / 100; + $response['BalanceAppliedCents'] = (int) round($balanceApplied * 100); + $response['FullyPaidByBalance'] = ($cashNeeded <= 0); + } + + jsonResponse($response); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => 'Failed to submit cash order', 'DETAIL' => $e->getMessage()]); +} diff --git a/api/orders/updateStatus.php b/api/orders/updateStatus.php new file mode 100644 index 0000000..3cc3f0b --- /dev/null +++ b/api/orders/updateStatus.php @@ -0,0 +1,48 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'OrderID and StatusID are required.']); +} + +try { + $qOrder = queryOne(" + SELECT o.ID, o.StatusID, o.BusinessID, o.ServicePointID, sp.Name + FROM Orders o + LEFT JOIN ServicePoints sp ON sp.ID = o.ServicePointID + WHERE o.ID = ? LIMIT 1 + ", [$OrderID]); + + if (!$qOrder) { + apiAbort(['OK' => false, 'ERROR' => 'not_found', 'MESSAGE' => 'Order not found.']); + } + + $oldStatusID = (int) $qOrder['StatusID']; + + queryTimed("UPDATE Orders SET StatusID = ?, LastEditedOn = NOW() WHERE ID = ?", [$NewStatusID, $OrderID]); + + // Create tasks when order moves to status 3 + require __DIR__ . '/_createOrderTasks.php'; + + jsonResponse([ + 'OK' => true, + 'ERROR' => '', + 'MESSAGE' => 'Order status updated successfully.', + 'OrderID' => $OrderID, + 'StatusID' => $NewStatusID, + 'TaskCreated' => $taskCreated, + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => 'DB error updating order status', 'DETAIL' => $e->getMessage()]); +} diff --git a/api/portal/addTeamMember.php b/api/portal/addTeamMember.php new file mode 100644 index 0000000..9760956 --- /dev/null +++ b/api/portal/addTeamMember.php @@ -0,0 +1,39 @@ + 3) $roleId = 1; + +if ($businessId <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'missing_business_id']); +} +if ($userId <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'missing_user_id']); +} + +// Check if already exists +$qCheck = queryOne( + "SELECT ID, IsActive FROM Employees WHERE BusinessID = ? AND UserID = ?", + [$businessId, $userId] +); + +if ($qCheck) { + // Reactivate with role + queryTimed( + "UPDATE Employees SET IsActive = 1, StatusID = 2, RoleID = ? WHERE BusinessID = ? AND UserID = ?", + [$roleId, $businessId, $userId] + ); + jsonResponse(['OK' => true, 'MESSAGE' => 'Employee reactivated', 'EmployeeID' => (int) $qCheck['ID']]); +} + +// Insert new +queryTimed( + "INSERT INTO Employees (BusinessID, UserID, StatusID, IsActive, RoleID) VALUES (?, ?, 2, 1, ?)", + [$businessId, $userId, $roleId] +); + +jsonResponse(['OK' => true, 'MESSAGE' => 'Team member added', 'EmployeeID' => (int) lastInsertId()]); diff --git a/api/portal/myBusinesses.php b/api/portal/myBusinesses.php new file mode 100644 index 0000000..e96d4f1 --- /dev/null +++ b/api/portal/myBusinesses.php @@ -0,0 +1,35 @@ + false, 'ERROR' => 'not_logged_in', 'MESSAGE' => 'User not authenticated']); +} + +$rows = queryTimed( + "SELECT b.ID, b.Name FROM Businesses b WHERE b.UserID = ? ORDER BY b.Name", + [$userID] +); + +$businesses = []; +foreach ($rows as $row) { + $businesses[] = [ + 'BusinessID' => (int) $row['ID'], + 'Name' => $row['Name'], + ]; +} + +jsonResponse([ + 'OK' => true, + 'BUSINESSES' => $businesses, + 'COUNT' => count($businesses), +]); diff --git a/api/portal/reassign_employees.php b/api/portal/reassign_employees.php new file mode 100644 index 0000000..d9eb53e --- /dev/null +++ b/api/portal/reassign_employees.php @@ -0,0 +1,15 @@ + true, + 'MESSAGE' => "All employee records reassigned to BusinessID $targetBusinessID", + 'COUNT' => (int) $qCount['cnt'], +]); diff --git a/api/portal/searchUser.php b/api/portal/searchUser.php new file mode 100644 index 0000000..bcd225d --- /dev/null +++ b/api/portal/searchUser.php @@ -0,0 +1,64 @@ + false, 'ERROR' => 'query_too_short', 'MESSAGE' => 'Enter at least 3 characters']); +} + +// Detect if phone or email +$isPhone = preg_match('/^[\d\s\-\(\)\+]+$/', $query) && strlen(normalizePhone($query)) >= 7; +$isEmail = str_contains($query, '@'); + +if ($isPhone) { + $phoneDigits = normalizePhone($query); + $qUser = queryOne( + "SELECT ID, FirstName, LastName, ContactNumber, EmailAddress + FROM Users + WHERE REPLACE(REPLACE(REPLACE(REPLACE(ContactNumber, '-', ''), ' ', ''), '(', ''), ')', '') LIKE ? + LIMIT 1", + ['%' . $phoneDigits . '%'] + ); +} elseif ($isEmail) { + $qUser = queryOne( + "SELECT ID, FirstName, LastName, ContactNumber, EmailAddress + FROM Users + WHERE EmailAddress LIKE ? + LIMIT 1", + ['%' . $query . '%'] + ); +} else { + $qUser = queryOne( + "SELECT ID, FirstName, LastName, ContactNumber, EmailAddress + FROM Users + WHERE FirstName LIKE ? OR LastName LIKE ? + OR CONCAT(FirstName, ' ', LastName) LIKE ? + LIMIT 1", + ['%' . $query . '%', '%' . $query . '%', '%' . $query . '%'] + ); +} + +if ($qUser) { + // Check if already on team + $qTeam = queryOne( + "SELECT ID FROM Employees WHERE BusinessID = ? AND UserID = ?", + [$businessId, (int) $qUser['ID']] + ); + + jsonResponse([ + 'OK' => true, + 'USER' => [ + 'UserID' => (int) $qUser['ID'], + 'Name' => trim($qUser['FirstName'] . ' ' . $qUser['LastName']), + 'Phone' => $qUser['ContactNumber'], + 'Email' => $qUser['EmailAddress'], + 'AlreadyOnTeam' => $qTeam !== null, + ], + ]); +} else { + jsonResponse(['OK' => true, 'USER' => null]); +} diff --git a/api/portal/stats.php b/api/portal/stats.php new file mode 100644 index 0000000..35cf9ec --- /dev/null +++ b/api/portal/stats.php @@ -0,0 +1,51 @@ + false, 'ERROR' => 'BusinessID is required']); +} + +$todayStart = gmdate('Y-m-d') . ' 00:00:00'; +$todayEnd = gmdate('Y-m-d') . ' 23:59:59'; + +// Orders today +$qOrdersToday = queryOne( + "SELECT COUNT(*) AS cnt FROM Orders + WHERE BusinessID = ? AND SubmittedOn >= ? AND SubmittedOn <= ?", + [$businessID, $todayStart, $todayEnd] +); + +// Revenue today +$qRevenueToday = queryOne( + "SELECT COALESCE(SUM(li.Quantity * li.Price), 0) AS total + FROM Orders o + JOIN OrderLineItems li ON li.OrderID = o.ID + WHERE o.BusinessID = ? AND o.SubmittedOn >= ? AND o.SubmittedOn <= ? AND o.StatusID >= 1", + [$businessID, $todayStart, $todayEnd] +); + +// Pending orders (status 1 = submitted, 2 = preparing) +$qPendingOrders = queryOne( + "SELECT COUNT(*) AS cnt FROM Orders WHERE BusinessID = ? AND StatusID IN (1, 2)", + [$businessID] +); + +// Active menu items +$qMenuItems = queryOne( + "SELECT COUNT(*) AS cnt FROM Items WHERE BusinessID = ? AND IsActive = 1 AND ParentItemID > 0", + [$businessID] +); + +jsonResponse([ + 'OK' => true, + 'STATS' => [ + 'ordersToday' => (int) $qOrdersToday['cnt'], + 'revenueToday' => (float) $qRevenueToday['total'], + 'pendingOrders' => (int) $qPendingOrders['cnt'], + 'menuItems' => (int) $qMenuItems['cnt'], + ], +]); diff --git a/api/portal/team.php b/api/portal/team.php new file mode 100644 index 0000000..01d94c8 --- /dev/null +++ b/api/portal/team.php @@ -0,0 +1,61 @@ + false, 'ERROR' => 'missing_business_id']); +} + +$rows = queryTimed( + "SELECT + e.ID, + e.UserID, + e.StatusID, + e.RoleID, + CAST(e.IsActive AS UNSIGNED) AS IsActive, + u.FirstName, + u.LastName, + u.EmailAddress, + u.ContactNumber, + COALESCE(sr.Name, 'Staff') AS RoleName, + CASE e.StatusID + WHEN 0 THEN 'Pending' + WHEN 1 THEN 'Invited' + WHEN 2 THEN 'Active' + WHEN 3 THEN 'Suspended' + ELSE 'Unknown' + END AS StatusName + FROM Employees e + JOIN Users u ON e.UserID = u.ID + LEFT JOIN tt_StaffRoles sr ON sr.ID = e.RoleID + WHERE e.BusinessID = ? + ORDER BY e.IsActive DESC, u.FirstName ASC", + [$businessId] +); + +$team = []; +foreach ($rows as $row) { + $team[] = [ + 'EmployeeID' => (int) $row['ID'], + 'UserID' => (int) $row['UserID'], + 'Name' => trim($row['FirstName'] . ' ' . $row['LastName']), + 'FirstName' => $row['FirstName'], + 'LastName' => $row['LastName'], + 'Email' => $row['EmailAddress'], + 'Phone' => $row['ContactNumber'], + 'RoleID' => (int) $row['RoleID'], + 'RoleName' => $row['RoleName'], + 'StatusID' => (int) $row['StatusID'], + 'StatusName' => $row['StatusName'], + 'IsActive' => (int) $row['IsActive'] === 1, + ]; +} + +jsonResponse([ + 'OK' => true, + 'TEAM' => $team, + 'COUNT' => count($team), +]); diff --git a/api/presence/heartbeat.php b/api/presence/heartbeat.php new file mode 100644 index 0000000..72c33cc --- /dev/null +++ b/api/presence/heartbeat.php @@ -0,0 +1,32 @@ + false, 'ERROR' => 'missing_UserID']); + if ($businessID === 0) apiAbort(['OK' => false, 'ERROR' => 'missing_BusinessID']); + + $params = [$userID, $businessID]; + $spVal = $servicePointID > 0 ? '?' : 'NULL'; + if ($servicePointID > 0) $params[] = $servicePointID; + + queryTimed(" + INSERT INTO UserPresence (UserID, BusinessID, ServicePointID, LastSeenOn) + VALUES (?, ?, $spVal, NOW()) + ON DUPLICATE KEY UPDATE + BusinessID = VALUES(BusinessID), + ServicePointID = VALUES(ServicePointID), + LastSeenOn = NOW() + ", $params); + + jsonResponse(['OK' => true]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/ratings/createAdminRating.php b/api/ratings/createAdminRating.php new file mode 100644 index 0000000..7f328dd --- /dev/null +++ b/api/ratings/createAdminRating.php @@ -0,0 +1,45 @@ + false, 'ERROR' => 'missing_task', 'MESSAGE' => 'TaskID is required.']); + if ($adminUserID === 0) apiAbort(['OK' => false, 'ERROR' => 'missing_admin', 'MESSAGE' => 'AdminUserID is required.']); + + $qTask = queryOne("SELECT ID, ClaimedByUserID, CompletedOn, BusinessID FROM Tasks WHERE ID = ?", [$taskID]); + if (!$qTask) apiAbort(['OK' => false, 'ERROR' => 'not_found', 'MESSAGE' => 'Task not found.']); + if (empty($qTask['CompletedOn'])) apiAbort(['OK' => false, 'ERROR' => 'not_completed', 'MESSAGE' => 'Task has not been completed yet.']); + + $workerUserID = (int) $qTask['ClaimedByUserID']; + if ($workerUserID === 0) apiAbort(['OK' => false, 'ERROR' => 'no_worker', 'MESSAGE' => 'No worker assigned to this task.']); + + $qExisting = queryOne("SELECT ID FROM TaskRatings WHERE TaskID = ? AND Direction = 'admin_rates_worker' LIMIT 1", [$taskID]); + if ($qExisting) apiAbort(['OK' => false, 'ERROR' => 'already_rated', 'MESSAGE' => 'This task has already been rated by an admin.']); + + $token = generateSecureToken(); + + queryTimed(" + INSERT INTO TaskRatings (TaskID, ByUserID, ForUserID, Direction, + OnTime, CompletedScope, RequiredFollowup, ContinueAllow, + AccessToken, ExpiresOn, CompletedOn) + VALUES (?, ?, ?, 'admin_rates_worker', ?, ?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 24 HOUR), NOW()) + ", [ + $taskID, $adminUserID, $workerUserID, + isset($data['onTime']) ? ($data['onTime'] ? 1 : 0) : null, + isset($data['completedScope']) ? ($data['completedScope'] ? 1 : 0) : null, + isset($data['requiredFollowup']) ? ($data['requiredFollowup'] ? 1 : 0) : null, + isset($data['continueAllow']) ? ($data['continueAllow'] ? 1 : 0) : null, + $token, + ]); + + $ratingID = (int) lastInsertId(); + + jsonResponse(['OK' => true, 'MESSAGE' => 'Rating submitted successfully.', 'RatingID' => $ratingID]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => 'Error creating rating', 'DETAIL' => $e->getMessage()]); +} diff --git a/api/ratings/listForAdmin.php b/api/ratings/listForAdmin.php new file mode 100644 index 0000000..382790a --- /dev/null +++ b/api/ratings/listForAdmin.php @@ -0,0 +1,58 @@ + false, 'ERROR' => 'missing_business', 'MESSAGE' => 'BusinessID is required.']); + } + + $rows = queryTimed(" + SELECT t.ID AS TaskID, t.Title, t.CompletedOn, t.ClaimedByUserID, t.OrderID, + u.FirstName AS WorkerFirstName, u.LastName AS WorkerLastName, + cu.FirstName AS CustomerFirstName, cu.LastName AS CustomerLastName, + sp.Name AS ServicePointName, + (SELECT COUNT(*) FROM TaskRatings r + WHERE r.TaskID = t.ID AND r.Direction = 'admin_rates_worker') AS HasAdminRating + FROM Tasks t + INNER JOIN Users u ON u.ID = t.ClaimedByUserID + LEFT JOIN Orders o ON o.ID = t.OrderID + LEFT JOIN Users cu ON cu.ID = o.UserID + LEFT JOIN ServicePoints sp ON sp.ID = o.ServicePointID + WHERE t.BusinessID = ? + AND t.CompletedOn IS NOT NULL + AND t.CompletedOn > DATE_SUB(NOW(), INTERVAL 7 DAY) + AND t.ClaimedByUserID > 0 + HAVING HasAdminRating = 0 + ORDER BY t.CompletedOn DESC + LIMIT 50 + ", [$businessID]); + + $tasks = []; + foreach ($rows as $r) { + $taskTitle = $r['Title'] ?? ''; + if ($taskTitle === '' && (int) ($r['OrderID'] ?? 0) > 0) $taskTitle = 'Order #' . $r['OrderID']; + if ($taskTitle === '') $taskTitle = 'Task #' . $r['TaskID']; + + $tasks[] = [ + 'TaskID' => (int) $r['TaskID'], + 'Title' => $taskTitle, + 'CompletedOn' => $r['CompletedOn'], + 'WorkerUserID' => (int) $r['ClaimedByUserID'], + 'WorkerName' => trim(($r['WorkerFirstName'] ?? '') . ' ' . ($r['WorkerLastName'] ?? '')), + 'CustomerName' => !empty($r['CustomerFirstName']) + ? trim($r['CustomerFirstName'] . ' ' . ($r['CustomerLastName'] ?? '')) + : '', + 'Name' => $r['ServicePointName'] ?? '', + 'OrderID' => (int) ($r['OrderID'] ?? 0), + ]; + } + + jsonResponse(['OK' => true, 'TASKS' => $tasks]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => 'Error loading tasks', 'DETAIL' => $e->getMessage()]); +} diff --git a/api/ratings/setup.php b/api/ratings/setup.php new file mode 100644 index 0000000..a8d3a3f --- /dev/null +++ b/api/ratings/setup.php @@ -0,0 +1,38 @@ + true, 'MESSAGE' => 'TaskRatings table created successfully', 'COLUMNS' => $colNames]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => $e->getMessage()]); +} diff --git a/api/ratings/submit.php b/api/ratings/submit.php new file mode 100644 index 0000000..d5fe9d5 --- /dev/null +++ b/api/ratings/submit.php @@ -0,0 +1,102 @@ + false, 'ERROR' => 'missing_token', 'MESSAGE' => 'Rating token is required.']); + } + + $qRating = queryOne(" + SELECT r.*, t.Title, + u_for.FirstName AS ForFirstName, u_for.LastName AS ForLastName, + u_by.FirstName AS ByFirstName, u_by.LastName AS ByLastName + FROM TaskRatings r + JOIN Tasks t ON t.ID = r.TaskID + LEFT JOIN Users u_for ON u_for.ID = r.ForUserID + LEFT JOIN Users u_by ON u_by.ID = r.ByUserID + WHERE r.AccessToken = ? LIMIT 1 + ", [$token]); + + if (!$qRating) { + apiAbort(['OK' => false, 'ERROR' => 'invalid_token', 'MESSAGE' => 'Rating not found or link is invalid.']); + } + + if (strtotime($qRating['ExpiresOn']) < time()) { + apiAbort(['OK' => false, 'ERROR' => 'expired', 'MESSAGE' => 'This rating link has expired.']); + } + + if (!empty($qRating['CompletedOn'])) { + apiAbort(['OK' => false, 'ERROR' => 'already_submitted', 'MESSAGE' => 'This rating has already been submitted.']); + } + + // Check if this is a submission or info request + $isSubmission = isset($data['onTime']) || isset($data['completedScope']) + || isset($data['requiredFollowup']) || isset($data['continueAllow']) + || isset($data['prepared']) || isset($data['respectful']) + || isset($data['wouldAutoAssign']); + + if (!$isSubmission) { + $result = [ + 'OK' => true, + 'RatingID' => (int) $qRating['ID'], + 'Direction' => $qRating['Direction'], + 'Title' => $qRating['Title'], + 'ForUserName' => trim($qRating['ForFirstName'] . ' ' . $qRating['ForLastName']), + 'ExpiresOn' => toISO8601($qRating['ExpiresOn']), + ]; + + if ($qRating['Direction'] === 'customer_rates_worker' || $qRating['Direction'] === 'admin_rates_worker') { + $result['Questions'] = [ + 'onTime' => 'Was the worker on time?', + 'completedScope' => 'Was the scope completed?', + 'requiredFollowup' => 'Was follow-up required?', + 'continueAllow' => 'Continue to allow these tasks?', + ]; + } elseif ($qRating['Direction'] === 'worker_rates_customer') { + $result['Questions'] = [ + 'prepared' => 'Was the customer prepared?', + 'completedScope' => 'Was the scope clear?', + 'respectful' => 'Was the customer respectful?', + 'wouldAutoAssign' => 'Would you serve this customer again?', + ]; + } + + jsonResponse($result); + } + + // Process submission + $ratingId = (int) $qRating['ID']; + + if ($qRating['Direction'] === 'customer_rates_worker' || $qRating['Direction'] === 'admin_rates_worker') { + queryTimed(" + UPDATE TaskRatings SET OnTime = ?, CompletedScope = ?, RequiredFollowup = ?, ContinueAllow = ?, CompletedOn = NOW() + WHERE ID = ? + ", [ + isset($data['onTime']) ? ($data['onTime'] ? 1 : 0) : null, + isset($data['completedScope']) ? ($data['completedScope'] ? 1 : 0) : null, + isset($data['requiredFollowup']) ? ($data['requiredFollowup'] ? 1 : 0) : null, + isset($data['continueAllow']) ? ($data['continueAllow'] ? 1 : 0) : null, + $ratingId, + ]); + } elseif ($qRating['Direction'] === 'worker_rates_customer') { + queryTimed(" + UPDATE TaskRatings SET Prepared = ?, CompletedScope = ?, Respectful = ?, WouldAutoAssign = ?, CompletedOn = NOW() + WHERE ID = ? + ", [ + isset($data['prepared']) ? ($data['prepared'] ? 1 : 0) : null, + isset($data['completedScope']) ? ($data['completedScope'] ? 1 : 0) : null, + isset($data['respectful']) ? ($data['respectful'] ? 1 : 0) : null, + isset($data['wouldAutoAssign']) ? ($data['wouldAutoAssign'] ? 1 : 0) : null, + $ratingId, + ]); + } + + jsonResponse(['OK' => true, 'MESSAGE' => 'Rating submitted successfully. Thank you for your feedback!']); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => 'Error submitting rating', 'DETAIL' => $e->getMessage()]); +} diff --git a/api/servicepoints/delete.php b/api/servicepoints/delete.php new file mode 100644 index 0000000..b8e8076 --- /dev/null +++ b/api/servicepoints/delete.php @@ -0,0 +1,29 @@ + false, 'ERROR' => 'no_business_selected']); +} + +$servicePointId = (int) ($data['ServicePointID'] ?? 0); +if ($servicePointId <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'missing_servicepoint_id', 'MESSAGE' => 'ServicePointID is required']); +} + +queryTimed("UPDATE ServicePoints SET IsActive = 0 WHERE ID = ? AND BusinessID = ?", + [$servicePointId, $bizId]); + +$qCheck = queryOne("SELECT ID, IsActive FROM ServicePoints WHERE ID = ? AND BusinessID = ? LIMIT 1", + [$servicePointId, $bizId]); + +if (!$qCheck) { + apiAbort(['OK' => false, 'ERROR' => 'not_found']); +} + +jsonResponse(['OK' => true, 'ERROR' => '', 'ServicePointID' => $servicePointId]); diff --git a/api/servicepoints/get.php b/api/servicepoints/get.php new file mode 100644 index 0000000..8000d48 --- /dev/null +++ b/api/servicepoints/get.php @@ -0,0 +1,37 @@ + false, 'ERROR' => 'missing_servicepoint_id', 'MESSAGE' => 'ServicePointID is required']); +} + +$q = queryOne(" + SELECT ID, BusinessID, Name, Code, TypeID, IsActive, SortOrder + FROM ServicePoints + WHERE ID = ? AND BusinessID = ? + LIMIT 1 +", [$servicePointId, $businessId]); + +if (!$q) { + apiAbort(['OK' => false, 'ERROR' => 'not_found']); +} + +jsonResponse([ + 'OK' => true, + 'ERROR' => '', + 'SERVICEPOINT' => [ + 'ServicePointID' => (int) $q['ID'], + 'BusinessID' => (int) $q['BusinessID'], + 'Name' => $q['Name'], + 'Code' => $q['Code'] ?? '', + 'TypeID' => (int) $q['TypeID'], + 'IsActive' => (int) $q['IsActive'], + 'SortOrder' => (int) $q['SortOrder'], + ], +]); diff --git a/api/servicepoints/list.php b/api/servicepoints/list.php new file mode 100644 index 0000000..655f697 --- /dev/null +++ b/api/servicepoints/list.php @@ -0,0 +1,89 @@ + false, 'ERROR' => 'missing_businessid']); +} + +$onlyActive = true; +if (isset($data['onlyActive'])) { + $v = $data['onlyActive']; + if (is_bool($v)) $onlyActive = $v; + elseif (is_numeric($v)) $onlyActive = ((int) $v === 1); + elseif (is_string($v)) $onlyActive = (strtolower(trim($v)) === 'true'); +} + +$sql = " + SELECT ID, Name, TypeID, Code, Description, SortOrder, IsActive, BeaconMinor + FROM ServicePoints WHERE BusinessID = ? +"; +$params = [$bizId]; +if ($onlyActive) $sql .= " AND IsActive = 1"; +$sql .= " ORDER BY SortOrder, Name"; + +$rows = queryTimed($sql, $params); + +$servicePoints = []; +foreach ($rows as $r) { + $servicePoints[] = [ + 'ServicePointID' => (int) $r['ID'], + 'Name' => $r['Name'], + 'TypeID' => (int) $r['TypeID'], + 'Code' => $r['Code'] ?? '', + 'Description' => $r['Description'] ?? '', + 'SortOrder' => (int) $r['SortOrder'], + 'IsActive' => (int) $r['IsActive'], + 'BeaconMinor' => (int) ($r['BeaconMinor'] ?? 0), + ]; +} + +// Granted service points (SP-SM) +$qGranted = queryTimed(" + SELECT sp.ID, sp.Name, sp.TypeID, sp.Code, sp.Description, sp.SortOrder, sp.IsActive, + g.ID AS GrantID, g.OwnerBusinessID, g.EconomicsType, g.EconomicsValue, + g.EligibilityScope, g.TimePolicyType, g.TimePolicyData, + ob.Name AS OwnerBusinessName + FROM ServicePointGrants g + JOIN ServicePoints sp ON sp.ID = g.ServicePointID + JOIN Businesses ob ON ob.ID = g.OwnerBusinessID + WHERE g.GuestBusinessID = ? AND g.StatusID = 1 AND sp.IsActive = 1 +", [$bizId]); + +$grantedServicePoints = []; +foreach ($qGranted as $r) { + if (isGrantTimeActive($r['TimePolicyType'] ?? 'always', $r['TimePolicyData'] ?? '')) { + $grantedServicePoints[] = [ + 'ServicePointID' => (int) $r['ID'], + 'Name' => $r['Name'], + 'TypeID' => (int) $r['TypeID'], + 'Code' => $r['Code'] ?? '', + 'Description' => $r['Description'] ?? '', + 'SortOrder' => (int) $r['SortOrder'], + 'IsActive' => (int) $r['IsActive'], + 'IsGranted' => true, + 'GrantID' => (int) $r['GrantID'], + 'OwnerBusinessID' => (int) $r['OwnerBusinessID'], + 'OwnerBusinessName' => $r['OwnerBusinessName'], + 'EconomicsType' => $r['EconomicsType'], + 'EconomicsValue' => (float) $r['EconomicsValue'], + 'EligibilityScope' => $r['EligibilityScope'], + ]; + } +} + +jsonResponse([ + 'OK' => true, + 'ERROR' => '', + 'BusinessID' => $bizId, + 'COUNT' => count($servicePoints), + 'SERVICEPOINTS' => $servicePoints, + 'GRANTED_COUNT' => count($grantedServicePoints), + 'GRANTED_SERVICEPOINTS' => $grantedServicePoints, +]); diff --git a/api/servicepoints/reassign_all.php b/api/servicepoints/reassign_all.php new file mode 100644 index 0000000..6e9dbb4 --- /dev/null +++ b/api/servicepoints/reassign_all.php @@ -0,0 +1,19 @@ + true, + 'MESSAGE' => "All service points reassigned to BusinessID $targetBusinessID", + 'COUNT' => (int) $qCount['cnt'], + ]); +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => $e->getMessage()]); +} diff --git a/api/servicepoints/save.php b/api/servicepoints/save.php new file mode 100644 index 0000000..dd60314 --- /dev/null +++ b/api/servicepoints/save.php @@ -0,0 +1,93 @@ + false, 'ERROR' => 'no_business_selected']); +} + +$spName = trim($data['Name'] ?? ''); +if ($spName === '') { + apiAbort(['OK' => false, 'ERROR' => 'missing_name', 'MESSAGE' => 'Name is required']); +} + +$servicePointId = (int) ($data['ServicePointID'] ?? 0); +$spCode = trim($data['Code'] ?? ''); +$spTypeID = (int) ($data['TypeID'] ?? 1); +$sortOrder = (int) ($data['SortOrder'] ?? 0); +$beaconMinor = isset($data['BeaconMinor']) && is_numeric($data['BeaconMinor']) ? (int) $data['BeaconMinor'] : -1; + +$isActive = 1; +if (isset($data['IsActive'])) { + $v = $data['IsActive']; + if (is_bool($v)) $isActive = $v ? 1 : 0; + elseif (is_numeric($v)) $isActive = (int) $v; + elseif (is_string($v)) $isActive = (strtolower(trim($v)) === 'true') ? 1 : 0; +} + +try { + if ($servicePointId > 0) { + // Update + $sql = "UPDATE ServicePoints SET Name = ?, Code = ?, TypeID = ?, IsActive = ?, SortOrder = ?"; + $params = [$spName, $spCode ?: null, $spTypeID, $isActive, $sortOrder]; + + if ($beaconMinor >= 0) { + $sql .= ", BeaconMinor = ?"; + $params[] = $beaconMinor; + } + + $sql .= " WHERE ID = ? AND BusinessID = ?"; + $params[] = $servicePointId; + $params[] = $businessId; + + queryTimed($sql, $params); + + $qCheck = queryOne("SELECT ID FROM ServicePoints WHERE ID = ? AND BusinessID = ? LIMIT 1", + [$servicePointId, $businessId]); + if (!$qCheck) { + apiAbort(['OK' => false, 'ERROR' => 'not_found']); + } + } else { + // Auto-allocate BeaconMinor if not provided + if ($beaconMinor < 0) { + $qMaxMinor = queryOne(" + SELECT COALESCE(MAX(BeaconMinor), -1) AS MaxMinor FROM ServicePoints WHERE BusinessID = ? + ", [$businessId]); + $beaconMinor = (int) $qMaxMinor['MaxMinor'] + 1; + } + + queryTimed(" + INSERT INTO ServicePoints (BusinessID, Name, Code, TypeID, IsActive, SortOrder, BeaconMinor) + VALUES (?, ?, ?, ?, ?, ?, ?) + ", [$businessId, $spName, $spCode ?: null, $spTypeID, $isActive, $sortOrder, $beaconMinor]); + + $servicePointId = (int) lastInsertId(); + } + + $qOut = queryOne(" + SELECT ID, BusinessID, Name, Code, TypeID, IsActive, SortOrder, BeaconMinor + FROM ServicePoints WHERE ID = ? AND BusinessID = ? LIMIT 1 + ", [$servicePointId, $businessId]); + + jsonResponse([ + 'OK' => true, + 'ERROR' => '', + 'SERVICEPOINT' => [ + 'ServicePointID' => (int) $qOut['ID'], + 'BusinessID' => (int) $qOut['BusinessID'], + 'Name' => $qOut['Name'], + 'Code' => $qOut['Code'] ?? '', + 'TypeID' => (int) $qOut['TypeID'], + 'IsActive' => (int) $qOut['IsActive'], + 'SortOrder' => (int) $qOut['SortOrder'], + 'BeaconMinor' => $qOut['BeaconMinor'] !== null ? (int) $qOut['BeaconMinor'] : '', + ], + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/setup/analyzeMenu.php b/api/setup/analyzeMenu.php new file mode 100644 index 0000000..4bc39b8 --- /dev/null +++ b/api/setup/analyzeMenu.php @@ -0,0 +1,289 @@ + false]; + +try { + $data = readJsonBody(); + if (empty($data)) throw new Exception('No request body provided'); + + $categories = $data['categories'] ?? []; + + $detectedTemplates = []; + $analyzedCategories = []; + + foreach ($categories as $cat) { + $catName = $cat['name'] ?? ''; + $catNote = $cat['categoryNote'] ?? ''; + $items = $cat['items'] ?? []; + + // Check for category-wide patterns in the note + $categoryTemplates = []; + + // "Add to any salad/item" pattern + if (preg_match('/add\s+(?:the\s+following\s+)?to\s+any\s+(salad|item)/i', $catNote)) { + if (preg_match('/add[^:]+:\s*(.+)/i', $catNote, $addOnMatch)) { + $addOnText = $addOnMatch[1]; + $addOnItems = array_map('trim', explode(',', $addOnText)); + $templateID = 'cat_' . strtolower(preg_replace('/\W+/', '_', $catName)) . '_addons'; + + if (!isset($detectedTemplates[$templateID])) { + $options = [['name' => 'None', 'price' => 0, 'isDefault' => true]]; + foreach ($addOnItems as $addon) { + $addon = trim($addon); + if (!empty($addon)) { + $options[] = ['name' => $addon, 'price' => 0, 'isDefault' => false]; + } + } + $detectedTemplates[$templateID] = [ + 'id' => $templateID, + 'name' => 'Add Protein', + 'options' => $options, + 'required' => false, + 'maxSelections' => 1, + 'detectedFrom' => "Category note: $catName", + 'appliesTo' => [], + ]; + } + $categoryTemplates[] = $templateID; + } + } + + // Analyze each item + $analyzedItems = []; + foreach ($items as $item) { + $itemName = $item['name'] ?? ''; + $itemDesc = $item['description'] ?? ''; + $itemMods = []; + + // Apply category-wide templates + foreach ($categoryTemplates as $catTmpl) { + $itemMods[] = $catTmpl; + $detectedTemplates[$catTmpl]['appliesTo'][] = $itemName; + } + + // Size patterns (small available) + if (preg_match('/\bsmall\s*(available|option)?\b/i', $itemDesc) || + preg_match('/\bsmall\b/i', $itemName)) { + $templateID = 'size_regular_small'; + if (!isset($detectedTemplates[$templateID])) { + $detectedTemplates[$templateID] = [ + 'id' => $templateID, + 'name' => 'Size', + 'options' => [ + ['name' => 'Regular', 'price' => 0, 'isDefault' => true], + ['name' => 'Small', 'price' => 0, 'isDefault' => false], + ], + 'required' => true, + 'maxSelections' => 1, + 'detectedFrom' => "Pattern: 'Small available'", + 'appliesTo' => [], + ]; + } + $itemMods[] = $templateID; + $detectedTemplates[$templateID]['appliesTo'][] = $itemName; + } + + // Half order pattern + if (preg_match('/\b(1\/2|half)\s*(order)?\s*(available)?\b/i', $itemDesc)) { + $templateID = 'size_full_half'; + if (!isset($detectedTemplates[$templateID])) { + $detectedTemplates[$templateID] = [ + 'id' => $templateID, + 'name' => 'Size', + 'options' => [ + ['name' => 'Full Order', 'price' => 0, 'isDefault' => true], + ['name' => 'Half Order', 'price' => 0, 'isDefault' => false], + ], + 'required' => true, + 'maxSelections' => 1, + 'detectedFrom' => "Pattern: '1/2 order available'", + 'appliesTo' => [], + ]; + } + $itemMods[] = $templateID; + $detectedTemplates[$templateID]['appliesTo'][] = $itemName; + } + + // Boneless or bone in + if (preg_match('/\bboneless\s+(or|\/)\s*bone\s*-?\s*in\b/i', $itemDesc)) { + $templateID = 'wing_style'; + if (!isset($detectedTemplates[$templateID])) { + $detectedTemplates[$templateID] = [ + 'id' => $templateID, + 'name' => 'Style', + 'options' => [ + ['name' => 'Bone-In', 'price' => 0, 'isDefault' => true], + ['name' => 'Boneless', 'price' => 0, 'isDefault' => false], + ], + 'required' => true, + 'maxSelections' => 1, + 'detectedFrom' => "Pattern: 'Boneless or bone in'", + 'appliesTo' => [], + ]; + } + $itemMods[] = $templateID; + $detectedTemplates[$templateID]['appliesTo'][] = $itemName; + } + + // Protein add-on (With Chicken / With Steak or Shrimp) + if (preg_match('/with\s+chicken.*(?:steak|shrimp)/i', $itemDesc)) { + $templateID = 'protein_addon'; + if (!isset($detectedTemplates[$templateID])) { + $detectedTemplates[$templateID] = [ + 'id' => $templateID, + 'name' => 'Add Protein', + 'options' => [ + ['name' => 'Plain', 'price' => 0, 'isDefault' => true], + ['name' => 'With Chicken', 'price' => 0, 'isDefault' => false], + ['name' => 'With Steak or Shrimp', 'price' => 0, 'isDefault' => false], + ], + 'required' => false, + 'maxSelections' => 1, + 'detectedFrom' => "Pattern: 'With Chicken / With Steak or Shrimp'", + 'appliesTo' => [], + ]; + } + $itemMods[] = $templateID; + $detectedTemplates[$templateID]['appliesTo'][] = $itemName; + } + + // "add X +$Y" pattern + if (preg_match_all('/add\s+(\w+(?:\s+(?:and\s+)?\w+)?)\s*[.\+]?\s*(\d+(?:\.\d{2})?)/i', $itemDesc, $priceMatches)) { + $templateID = 'addon_' . strtolower(preg_replace('/\W+/', '_', $itemName)); + if (!isset($detectedTemplates[$templateID])) { + $detectedTemplates[$templateID] = [ + 'id' => $templateID, + 'name' => 'Add-ons', + 'options' => [], + 'required' => false, + 'maxSelections' => 0, + 'detectedFrom' => "Pattern: 'Add X +\$Y' in $itemName", + 'appliesTo' => [], + 'needsPricing' => true, + ]; + } + $itemMods[] = $templateID; + $detectedTemplates[$templateID]['appliesTo'][] = $itemName; + } + + // "with guacamole" pattern + if (preg_match('/\bwith\s+guacamole\b/i', $itemDesc)) { + $templateID = 'addon_guacamole'; + if (!isset($detectedTemplates[$templateID])) { + $detectedTemplates[$templateID] = [ + 'id' => $templateID, + 'name' => 'Add Guacamole', + 'options' => [ + ['name' => 'No Guacamole', 'price' => 0, 'isDefault' => true], + ['name' => 'Add Guacamole', 'price' => 0, 'isDefault' => false], + ], + 'required' => false, + 'maxSelections' => 1, + 'detectedFrom' => "Pattern: 'With guacamole'", + 'appliesTo' => [], + 'needsPricing' => true, + ]; + } + $itemMods[] = $templateID; + $detectedTemplates[$templateID]['appliesTo'][] = $itemName; + } + + $analyzedItems[] = [ + 'name' => $itemName, + 'description' => $itemDesc, + 'price' => $item['price'] ?? 0, + 'detectedModifiers' => $itemMods, + 'originalDescription' => $itemDesc, + ]; + } + + $analyzedCategories[] = [ + 'name' => $catName, + 'categoryNote' => $catNote, + 'items' => $analyzedItems, + 'categoryWideTemplates' => $categoryTemplates, + ]; + } + + // Build template array sorted by usage count + $templateArray = array_values($detectedTemplates); + foreach ($templateArray as &$tmpl) { + $tmpl['usageCount'] = count($tmpl['appliesTo']); + } + unset($tmpl); + usort($templateArray, fn($a, $b) => $b['usageCount'] - $a['usageCount']); + + // Generate questions + $questions = []; + + if (!empty($templateArray)) { + $questions[] = [ + 'type' => 'confirm_templates', + 'question' => 'I detected these modifier groups. Are they correct?', + 'templates' => $templateArray, + ]; + } + + $itemsNeedingPrices = []; + foreach ($analyzedCategories as $cat) { + foreach ($cat['items'] as $item) { + if (($item['price'] ?? 0) == 0) { + $itemsNeedingPrices[] = ['category' => $cat['name'], 'item' => $item['name']]; + } + } + } + if (!empty($itemsNeedingPrices)) { + $questions[] = ['type' => 'pricing_needed', 'question' => 'These items need prices:', 'items' => $itemsNeedingPrices]; + } + + $templatesNeedingPrices = []; + foreach ($templateArray as $tmpl) { + if (!empty($tmpl['needsPricing'])) { + $templatesNeedingPrices[] = [ + 'templateId' => $tmpl['id'], + 'templateName' => $tmpl['name'], + 'appliesTo' => $tmpl['appliesTo'], + ]; + } + } + if (!empty($templatesNeedingPrices)) { + $questions[] = ['type' => 'modifier_pricing_needed', 'question' => 'These add-on options need prices:', 'templates' => $templatesNeedingPrices]; + } + + // Count totals + $totalItems = 0; + $totalLinks = 0; + foreach ($analyzedCategories as $cat) { + $totalItems += count($cat['items']); + foreach ($cat['items'] as $item) { + $totalLinks += count($item['detectedModifiers']); + } + } + + $response['OK'] = true; + $response['analyzedMenu'] = [ + 'categories' => $analyzedCategories, + 'detectedTemplates' => $templateArray, + 'totalItems' => $totalItems, + 'totalTemplates' => count($templateArray), + 'totalModifierLinks' => $totalLinks, + ]; + $response['questions'] = $questions; + $response['summary'] = "Analyzed $totalItems items, detected " . count($templateArray) . " modifier templates with $totalLinks links"; + +} catch (Exception $e) { + $response['error'] = $e->getMessage(); +} + +jsonResponse($response); diff --git a/api/setup/analyzeMenuImages.php b/api/setup/analyzeMenuImages.php new file mode 100644 index 0000000..5f72786 --- /dev/null +++ b/api/setup/analyzeMenuImages.php @@ -0,0 +1,344 @@ + false]; + +try { + // Load API Key + $CLAUDE_API_KEY = ''; + $configPath = __DIR__ . '/../../config/claude.json'; + if (file_exists($configPath)) { + $configData = json_decode(file_get_contents($configPath), true); + $CLAUDE_API_KEY = $configData['apiKey'] ?? ''; + } + if (empty($CLAUDE_API_KEY)) throw new Exception('Claude API key not configured'); + + // Find uploaded files + $uploadedFiles = []; + foreach ($_FILES as $fieldName => $fileInfo) { + if (preg_match('/^file[0-9]+$/', $fieldName) && !empty($fileInfo['tmp_name'])) { + $uploadedFiles[] = $fieldName; + } + } + if (empty($uploadedFiles)) throw new Exception('No files uploaded'); + + // Process all images - read and encode + $imageDataArray = []; + foreach ($uploadedFiles as $fieldName) { + $file = $_FILES[$fieldName]; + $filePath = $file['tmp_name']; + $fileExt = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); + + // Resize large images + if (in_array($fileExt, ['jpg', 'jpeg', 'png', 'gif', 'webp'])) { + $imageInfo = getimagesize($filePath); + if ($imageInfo && ($imageInfo[0] > 1600 || $imageInfo[1] > 1600)) { + $src = null; + switch ($imageInfo[2]) { + case IMAGETYPE_JPEG: $src = imagecreatefromjpeg($filePath); break; + case IMAGETYPE_PNG: $src = imagecreatefrompng($filePath); break; + case IMAGETYPE_GIF: $src = imagecreatefromgif($filePath); break; + case IMAGETYPE_WEBP: $src = imagecreatefromwebp($filePath); break; + } + if ($src) { + $w = imagesx($src); + $h = imagesy($src); + if ($w > $h) { + $newW = 1600; + $newH = (int)($h * (1600 / $w)); + } else { + $newH = 1600; + $newW = (int)($w * (1600 / $h)); + } + $dst = imagecreatetruecolor($newW, $newH); + imagecopyresampled($dst, $src, 0, 0, 0, 0, $newW, $newH, $w, $h); + imagejpeg($dst, $filePath, 80); + imagedestroy($src); + imagedestroy($dst); + $fileExt = 'jpg'; // resized to JPEG + } + } + } + + $base64Content = base64_encode(file_get_contents($filePath)); + + $mediaTypes = [ + 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', + 'png' => 'image/png', 'gif' => 'image/gif', + 'webp' => 'image/webp', 'pdf' => 'application/pdf', + ]; + $mediaType = $mediaTypes[$fileExt] ?? 'image/jpeg'; + + $imgStruct = [ + 'type' => ($fileExt === 'pdf') ? 'document' : 'image', + 'source' => [ + 'type' => 'base64', + 'media_type' => $mediaType, + 'data' => $base64Content, + ], + ]; + $imageDataArray[] = $imgStruct; + } + + if (empty($imageDataArray)) throw new Exception('No valid images could be processed'); + + $systemPrompt = 'You are an expert at extracting structured menu data from restaurant menu images. Extract ALL data visible on THIS image only. Return valid JSON with these keys: business (object with name, address, phone, hours, brandColor - only if visible), categories (array of category names visible), modifiers (array of modifier templates with name, required boolean, appliesTo (string: \'category\', \'item\', or \'uncertain\'), categoryName (if appliesTo=\'category\'), and options array where each option is an object with \'name\' and \'price\' keys), items (array with name, description, price, category, and modifiers array), isHeaderCandidate (boolean - true if this image shows a restaurant interior, exterior, food photography, logo/branding, or banner that would work well as a menu header image). For brandColor: Extract the dominant accent/brand color from the menu design (logo, headers, accent elements). Return as a 6-digit hex code WITHOUT the # symbol (e.g. "E74C3C" for red). Choose a vibrant, appealing color that represents the restaurant\'s brand. CRITICAL for hours: Extract ALL days\' hours including Saturday and Sunday. Format as a single string with ALL days, e.g. "Mon-Fri 10:30am-10pm, Sat 11am-10pm, Sun 11am-9pm". For modifier options, ALWAYS use format: {"name": "option name", "price": 0}. IMPORTANT: Look for modifiers in these locations: (1) text under category headers, (2) item descriptions, (3) asterisk notes, (4) header/footer text. For modifiers found under category headers, set appliesTo=\'category\' and categoryName to the category. For modifiers clearly in item descriptions, assign them to that item\'s modifiers array. For modifiers where you\'re uncertain how they apply, set appliesTo=\'uncertain\' and do NOT assign to items. Return ONLY valid JSON, no markdown, no explanation.'; + + // Process each image individually + $allResults = []; + + foreach ($imageDataArray as $imgIndex => $imgData) { + $requestBody = json_encode([ + 'model' => 'claude-sonnet-4-20250514', + 'max_tokens' => 16384, + 'temperature' => 0, + 'system' => $systemPrompt, + 'messages' => [[ + 'role' => 'user', + 'content' => [ + $imgData, + [ + 'type' => 'text', + 'text' => 'Extract all menu data from this image. Return JSON with: business (if visible), categories, modifiers (with appliesTo, categoryName if applicable, and options as objects with name and price keys), items (with modifiers array only for item-specific modifiers).', + ], + ], + ]], + ]); + + $ch = curl_init('https://api.anthropic.com/v1/messages'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 300, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $requestBody, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + "x-api-key: $CLAUDE_API_KEY", + 'anthropic-version: 2023-06-01', + ], + ]); + $result = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode !== 200) { + $errorDetail = ''; + $errorResponse = json_decode($result, true); + if (isset($errorResponse['error']['message'])) { + $errorDetail = $errorResponse['error']['message']; + } else { + $errorDetail = $result; + } + throw new Exception("Claude API error on image " . ($imgIndex + 1) . ": $httpCode - $errorDetail"); + } + + $claudeResponse = json_decode($result, true); + if (empty($claudeResponse['content'])) { + throw new Exception("Empty response from Claude for image " . ($imgIndex + 1)); + } + + $responseText = ''; + foreach ($claudeResponse['content'] as $block) { + if (($block['type'] ?? '') === 'text') { + $responseText = $block['text']; + break; + } + } + + // Clean up JSON response + $responseText = trim($responseText); + if (str_starts_with($responseText, '```json')) $responseText = substr($responseText, 7); + if (str_starts_with($responseText, '```')) $responseText = substr($responseText, 3); + if (str_ends_with($responseText, '```')) $responseText = substr($responseText, 0, -3); + $responseText = trim($responseText); + // Remove trailing commas before ] or } + $responseText = preg_replace('/,(\s*[\]\}])/', '$1', $responseText); + // Clean control characters + $responseText = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $responseText); + + $imageResult = json_decode($responseText, true); + if ($imageResult === null) { + // Save debug file + $uploadsPath = isDev() ? '/opt/lucee/tomcat/webapps/ROOT/uploads' : '/var/www/biz.payfrit.com/uploads'; + file_put_contents("$uploadsPath/debug_claude.json", $responseText); + throw new Exception("JSON parse error for image " . ($imgIndex + 1) . ". Debug saved to /uploads/debug_claude.json"); + } + $allResults[] = $imageResult; + } + + // MERGE PHASE + + // Track header candidates + $headerCandidateIndices = []; + foreach ($allResults as $resultIdx => $result) { + if (!empty($result['isHeaderCandidate'])) { + $headerCandidateIndices[] = $resultIdx; // 0-indexed + } + } + + // 1. Merge business info (last image wins) + $mergedBusiness = []; + $bizFields = ['name','address','addressLine1','city','state','zip','phone','hours','brandColor']; + foreach ($allResults as $result) { + if (!empty($result['business']) && is_array($result['business'])) { + foreach ($bizFields as $fieldName) { + if (!isset($result['business'][$fieldName])) continue; + $fieldVal = $result['business'][$fieldName]; + if (is_string($fieldVal) && strlen(trim($fieldVal))) { + $mergedBusiness[$fieldName] = trim($fieldVal); + } elseif (is_array($fieldVal)) { + // Convert array/struct to readable string + $parts = []; + foreach ($fieldVal as $k => $v) { + if (is_string($v) && strlen(trim($v))) { + $parts[] = (is_int($k) ? '' : "$k: ") . $v; + } elseif (is_array($v)) { + $entryParts = []; + foreach ($v as $ev) { + if (is_string($ev)) $entryParts[] = $ev; + } + if (!empty($entryParts)) $parts[] = implode(' ', $entryParts); + } + } + if (!empty($parts)) $mergedBusiness[$fieldName] = implode(', ', $parts); + } + } + } + } + + // 2. Merge categories (dedupe by name) + $categoryMap = []; + foreach ($allResults as $result) { + foreach (($result['categories'] ?? []) as $cat) { + $catName = ''; + if (is_string($cat) && strlen(trim($cat))) { + $catName = trim($cat); + } elseif (is_array($cat)) { + $catName = trim($cat['name'] ?? $cat['category'] ?? $cat['title'] ?? ''); + } + if (strlen($catName)) { + $categoryMap[strtolower($catName)] = $catName; + } + } + } + $mergedCategories = []; + foreach ($categoryMap as $catName) { + $mergedCategories[] = ['name' => $catName, 'itemCount' => 0]; + } + + // 3. Merge modifiers (dedupe by name) + $modifierMap = []; + foreach ($allResults as $resultIndex => $result) { + foreach (($result['modifiers'] ?? []) as $mod) { + if (!is_array($mod) || empty($mod['name'])) continue; + $modKey = strtolower($mod['name']); + if (isset($modifierMap[$modKey])) continue; + + $normalizedMod = [ + 'name' => trim($mod['name']), + 'required' => !empty($mod['required']), + 'appliesTo' => $mod['appliesTo'] ?? 'uncertain', + 'sourceImageIndex' => $resultIndex + 1, + 'options' => [], + ]; + if (!empty($mod['categoryName'])) { + $normalizedMod['categoryName'] = trim($mod['categoryName']); + } + + foreach (($mod['options'] ?? []) as $opt) { + $normalizedOpt = []; + if (is_string($opt) && strlen(trim($opt))) { + $normalizedOpt = ['name' => trim($opt), 'price' => 0]; + } elseif (is_array($opt)) { + $optName = trim($opt['name'] ?? $opt['option'] ?? $opt['label'] ?? ''); + if (strlen($optName)) { + $optPrice = 0; + if (isset($opt['price'])) { + if (is_numeric($opt['price'])) { + $optPrice = (float)$opt['price']; + } elseif (is_string($opt['price'])) { + $priceStr = preg_replace('/[^0-9.]/', '', $opt['price']); + if (is_numeric($priceStr)) $optPrice = (float)$priceStr; + } + } + $normalizedOpt = ['name' => $optName, 'price' => $optPrice]; + } + } + if (!empty($normalizedOpt['name'])) { + $normalizedMod['options'][] = $normalizedOpt; + } + } + + if (!empty($normalizedMod['options'])) { + $modifierMap[$modKey] = $normalizedMod; + } + } + } + $mergedModifiers = array_values($modifierMap); + + // 4. Merge items + $mergedItems = []; + $itemIndex = 0; + foreach ($allResults as $result) { + foreach (($result['items'] ?? []) as $item) { + $itemIndex++; + $item['id'] = 'item_' . $itemIndex; + $mergedItems[] = $item; + } + } + + // 5. Auto-assign category-level modifiers to items + foreach ($mergedItems as &$item) { + if (!isset($item['modifiers']) || !is_array($item['modifiers'])) { + $item['modifiers'] = []; + } + $itemCategory = is_string($item['category'] ?? null) ? trim($item['category']) : ''; + if (strlen($itemCategory)) { + foreach ($mergedModifiers as $mod) { + if (($mod['appliesTo'] ?? '') === 'category' && !empty($mod['categoryName'])) { + if (strtolower($mod['categoryName']) === strtolower($itemCategory)) { + // Check not already assigned + $alreadyAssigned = false; + foreach ($item['modifiers'] as $existingMod) { + $existingModName = is_string($existingMod) ? $existingMod : ($existingMod['name'] ?? ''); + if (strlen($existingModName) && strtolower($existingModName) === strtolower($mod['name'])) { + $alreadyAssigned = true; + break; + } + } + if (!$alreadyAssigned) { + $item['modifiers'][] = $mod['name']; + } + } + } + } + } + } + unset($item); + + $response['OK'] = true; + $response['DATA'] = [ + 'business' => $mergedBusiness, + 'categories' => $mergedCategories, + 'modifiers' => $mergedModifiers, + 'items' => $mergedItems, + 'headerCandidateIndices' => $headerCandidateIndices, + ]; + $response['imagesProcessed'] = count($imageDataArray); + $response['DEBUG_RAW_RESULTS'] = $allResults; + +} catch (Exception $e) { + $response['MESSAGE'] = $e->getMessage(); +} + +jsonResponse($response); diff --git a/api/setup/analyzeMenuUrl.php b/api/setup/analyzeMenuUrl.php new file mode 100644 index 0000000..d28ae3a --- /dev/null +++ b/api/setup/analyzeMenuUrl.php @@ -0,0 +1,1922 @@ + false]; + +try { + // Load API Key + $configPath = realpath(__DIR__ . '/../../config/claude.json'); + $CLAUDE_API_KEY = ''; + if ($configPath && file_exists($configPath)) { + $configData = json_decode(file_get_contents($configPath), true); + if (!empty($configData['apiKey'])) { + $CLAUDE_API_KEY = $configData['apiKey']; + } + } + if (empty($CLAUDE_API_KEY)) { + throw new Exception('Claude API key not configured'); + } + + $data = readJsonBody(); + if (empty($data)) throw new Exception('No request body provided'); + + $response['steps'] = []; + $response['debug'] = [ + 'hasHtmlKey' => isset($data['html']), + 'hasUrlKey' => isset($data['url']), + 'htmlLength' => isset($data['html']) ? strlen($data['html']) : 0, + 'urlValue' => $data['url'] ?? '', + ]; + + $pageHtml = ''; + $baseUrl = ''; + $basePath = ''; + $targetUrl = ''; + $playwrightImages = []; + + // Helper: webroot path + $webroot = isDev() + ? '/opt/lucee/tomcat/webapps/ROOT' + : '/var/www/biz.payfrit.com'; + + // Helper: expand a URL path to a local file path + $expandPath = function(string $urlPath) use ($webroot): string { + return $webroot . $urlPath; + }; + + // Helper: convert 24h time to 12h format string + $formatTime12h = function(int $h, int $m): string { + $ampm = $h >= 12 ? 'pm' : 'am'; + if ($h > 12) $h -= 12; + if ($h === 0) $h = 12; + return $h . ($m > 0 ? ':' . str_pad($m, 2, '0', STR_PAD_LEFT) : '') . $ampm; + }; + + // Helper: extract value from escaped JSON using backslash-quote markers + $BQ = "\\\""; // backslash-quote as it appears in HTML + + $extractBqValue = function(string $text, string $key, int $startPos = 0) use ($BQ): ?string { + $marker = $BQ . $key . $BQ . ':' . $BQ; + $pos = stripos($text, $marker, $startPos); + if ($pos === false) return null; + $valStart = $pos + strlen($marker); + $valEnd = strpos($text, $BQ, $valStart); + if ($valEnd === false || $valEnd <= $valStart) return null; + return substr($text, $valStart, $valEnd - $valStart); + }; + + // Helper: extract __OO_STATE__ JSON using brace-counting + $extractOoState = function(string $html): ?string { + $ooStart = stripos($html, 'window.__OO_STATE__'); + if ($ooStart === false) return null; + $braceStart = strpos($html, '{', $ooStart); + if ($braceStart === false) return null; + + $depth = 0; + $inStr = false; + $esc = false; + $totalLen = strlen($html); + $braceEnd = 0; + + for ($i = $braceStart; $i < $totalLen; $i++) { + $ch = $html[$i]; + if ($esc) { $esc = false; continue; } + if ($ch === '\\' && $inStr) { $esc = true; continue; } + if ($ch === '"') { $inStr = !$inStr; continue; } + if (!$inStr) { + if ($ch === '{') $depth++; + elseif ($ch === '}') { + $depth--; + if ($depth === 0) { $braceEnd = $i; break; } + } + } + } + + if ($braceEnd === 0) return null; + $json = substr($html, $braceStart, $braceEnd - $braceStart + 1); + + // Decode HTML entities from View Source + $json = str_replace(['&', '<', '>', '"'], ['&', '<', '>', '"'], $json); + return $json; + }; + + // Helper: extract Toast item price from multiple possible fields + $extractToastPrice = function(array $item): float { + if (!empty($item['prices']) && is_array($item['prices']) && is_numeric($item['prices'][0] ?? null)) { + return (float)$item['prices'][0]; + } + if (isset($item['price']) && is_numeric($item['price'])) return (float)$item['price']; + if (isset($item['unitPrice']) && is_numeric($item['unitPrice'])) return (float)$item['unitPrice']; + if (isset($item['basePrice']) && is_numeric($item['basePrice'])) return (float)$item['basePrice']; + if (isset($item['displayPrice']) && strlen(trim((string)$item['displayPrice']))) { + $ps = preg_replace('/[^0-9.]/', '', (string)$item['displayPrice']); + if (strlen($ps) && is_numeric($ps)) return (float)$ps; + } + return 0.0; + }; + + // Helper: extract Toast item image URL + $extractToastImage = function(array $item): string { + if (isset($item['imageUrls']) && is_array($item['imageUrls'])) { + $urls = $item['imageUrls']; + return $urls['medium'] ?? $urls['large'] ?? $urls['small'] ?? ''; + } + return ''; + }; + + // Helper: clean JSON from Claude response + $cleanClaudeJson = function(string $text): string { + $text = trim($text); + // Strip markdown code fences + if (str_starts_with($text, '```json')) $text = substr($text, 7); + if (str_starts_with($text, '```')) $text = substr($text, 3); + if (str_ends_with($text, '```')) $text = substr($text, 0, -3); + $text = trim($text); + // Extract JSON object if text doesn't start with { + if (!str_starts_with($text, '{')) { + $jsonStart = strpos($text, '{'); + if ($jsonStart !== false) { + $text = substr($text, $jsonStart); + if (str_ends_with(trim($text), '```')) { + $text = substr(trim($text), 0, -3); + } + $text = trim($text); + } + } + // Remove trailing commas before ] or } + $text = preg_replace('/,(\s*[\]\}])/', '$1', $text); + // Remove control characters + $text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $text); + // Clean smart quotes/dashes + $text = str_replace(["\xe2\x80\x98", "\xe2\x80\x99"], "'", $text); // smart single quotes + $text = str_replace(["\xe2\x80\x93", "\xe2\x80\x94"], "-", $text); // en/em dash + $text = str_replace("\xe2\x80\xa6", "...", $text); // ellipsis + return $text; + }; + + // Helper: detect media type from base64 prefix + $detectMediaType = function(string $base64): string { + if (str_starts_with($base64, 'iVBO')) return 'image/png'; + if (str_starts_with($base64, 'R0lGOD')) return 'image/gif'; + if (str_starts_with($base64, 'UklGR')) return 'image/webp'; + return 'image/jpeg'; + }; + + // Helper: HTTP GET with curl + $httpGet = function(string $url, array $headers = [], int $timeout = 30): array { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => $timeout, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_HTTPHEADER => $headers, + ]); + $body = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE); + curl_close($ch); + return ['body' => $body, 'code' => $code, 'contentType' => $contentType ?? '']; + }; + + // Helper: HTTP POST with curl + $httpPost = function(string $url, string $body, array $headers = [], int $timeout = 30): array { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $body, + CURLOPT_TIMEOUT => $timeout, + CURLOPT_HTTPHEADER => $headers, + ]); + $result = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + return ['body' => $result, 'code' => $code]; + }; + + // ============================================================ + // Parse request: HTML content or URL + // ============================================================ + + if (!empty($data['html'])) { + $pageHtml = trim($data['html']); + $response['steps'][] = "Using provided HTML content: " . strlen($pageHtml) . " bytes"; + } elseif (!empty($data['url'])) { + $targetUrl = trim($data['url']); + if (!preg_match('#^https?://#i', $targetUrl)) { + $targetUrl = 'https://' . $targetUrl; + } + + // ========== GRUBHUB FAST PATH ========== + if (preg_match('#grubhub\.com/restaurant/#i', $targetUrl)) { + $response['steps'][] = "Grubhub URL detected - using API"; + + // Extract restaurant ID + if (!preg_match('#/(\d+)(\?|$)#', $targetUrl, $ghIdMatch)) { + throw new Exception('Could not extract Grubhub restaurant ID from URL'); + } + $ghRestaurantId = $ghIdMatch[1]; + $response['steps'][] = "Grubhub restaurant ID: $ghRestaurantId"; + + // Get anonymous access token + $ghAuth = $httpPost( + 'https://api-gtm.grubhub.com/auth', + '{"brand":"GRUBHUB","client_id":"beta_UmWlpstzQSFmocLy3h1UieYcVST","scope":"anonymous"}', + ['Content-Type: application/json'], + 15 + ); + if ($ghAuth['code'] !== 200) throw new Exception("Grubhub auth failed: {$ghAuth['code']}"); + $ghAuthData = json_decode($ghAuth['body'], true); + $ghToken = $ghAuthData['session_handle']['access_token']; + $response['steps'][] = "Got Grubhub anonymous token"; + + // Fetch restaurant with full menu data + $ghMenu = $httpGet( + "https://api-gtm.grubhub.com/restaurants/$ghRestaurantId?hideChoiceCategories=false&version=4&orderType=standard&hideUnavailableMenuItems=false&hideMenuItems=false", + ["Authorization: Bearer $ghToken"], + 30 + ); + if ($ghMenu['code'] !== 200) throw new Exception("Grubhub restaurant fetch failed: {$ghMenu['code']}"); + $ghData = json_decode($ghMenu['body'], true); + $ghRestaurant = $ghData['restaurant']; + $response['steps'][] = "Fetched Grubhub restaurant data (" . strlen($ghMenu['body']) . " bytes)"; + + // Parse business info + $ghBusiness = ['name' => $ghRestaurant['name']]; + if (!empty($ghRestaurant['address']) && is_array($ghRestaurant['address'])) { + $ghAddr = $ghRestaurant['address']; + if (isset($ghAddr['street_address'])) $ghBusiness['addressLine1'] = $ghAddr['street_address']; + if (isset($ghAddr['locality'])) $ghBusiness['city'] = $ghAddr['locality']; + if (isset($ghAddr['region'])) $ghBusiness['state'] = $ghAddr['region']; + if (isset($ghAddr['zip'])) $ghBusiness['zip'] = $ghAddr['zip']; + $ghBusiness['address'] = ($ghBusiness['addressLine1'] ?? '') . ', ' . ($ghBusiness['city'] ?? '') . ', ' . ($ghBusiness['state'] ?? '') . ' ' . ($ghBusiness['zip'] ?? ''); + } + if (isset($ghRestaurant['latitude']) && is_numeric($ghRestaurant['latitude'])) $ghBusiness['latitude'] = $ghRestaurant['latitude']; + if (isset($ghRestaurant['longitude']) && is_numeric($ghRestaurant['longitude'])) $ghBusiness['longitude'] = $ghRestaurant['longitude']; + if (!empty($ghRestaurant['phone_number'])) $ghBusiness['phone'] = preg_replace('/[^0-9]/', '', $ghRestaurant['phone_number']); + if (!empty(trim($ghRestaurant['description'] ?? ''))) $ghBusiness['description'] = trim($ghRestaurant['description']); + + // Hours + $ghHoursParts = []; + $ghDayOrder = ['Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday']; + $ghDayAbbrev = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun']; + if (!empty($ghRestaurant['restaurant_managed_hours_list_v2']) && is_array($ghRestaurant['restaurant_managed_hours_list_v2'])) { + foreach ($ghRestaurant['restaurant_managed_hours_list_v2'] as $ghDayHours) { + if (isset($ghDayHours['day'], $ghDayHours['start_time'], $ghDayHours['end_time'])) { + $ghDayIdx = array_search($ghDayHours['day'], $ghDayOrder); + if ($ghDayIdx !== false) { + $parts = explode(':', $ghDayHours['start_time']); + $openStr = $formatTime12h((int)$parts[0], (int)($parts[1] ?? 0)); + $parts = explode(':', $ghDayHours['end_time']); + $closeStr = $formatTime12h((int)$parts[0], (int)($parts[1] ?? 0)); + $ghHoursParts[] = $ghDayAbbrev[$ghDayIdx] . " $openStr-$closeStr"; + } + } + } + } + if (!empty($ghHoursParts)) $ghBusiness['hours'] = implode(', ', $ghHoursParts); + if (isset($ghData['restaurant_availability']['sales_tax'])) $ghBusiness['taxRate'] = $ghData['restaurant_availability']['sales_tax']; + + // Parse categories and items + $ghCategories = []; + $ghItems = []; + $ghItemId = 1; + $ghModifierGroups = []; + $ghImageMappings = []; + + if (!empty($ghRestaurant['menu_category_list']) && is_array($ghRestaurant['menu_category_list'])) { + foreach ($ghRestaurant['menu_category_list'] as $ghCat) { + $ghCatName = trim($ghCat['name'] ?? 'Menu'); + $ghCatItemCount = 0; + + if (!empty($ghCat['menu_item_list']) && is_array($ghCat['menu_item_list'])) { + foreach ($ghCat['menu_item_list'] as $ghItem) { + $ghItemName = trim($ghItem['name'] ?? ''); + if (empty($ghItemName)) continue; + + $ghPrice = 0; + if (!empty($ghItem['price']['amount'])) $ghPrice = (float)$ghItem['price']['amount'] / 100; + $ghDesc = trim($ghItem['description'] ?? ''); + + // Image URL + $ghImageUrl = ''; + if (!empty($ghItem['media_image']) && is_array($ghItem['media_image'])) { + $gi = $ghItem['media_image']; + if (!empty($gi['base_url']) && !empty($gi['public_id']) && !empty($gi['format'])) { + $ghImageUrl = $gi['base_url'] . 'w_400,h_400,c_fill/' . $gi['public_id'] . '.' . $gi['format']; + } + } + + // Modifiers + $ghItemModifiers = []; + if (!empty($ghItem['choice_category_list']) && is_array($ghItem['choice_category_list'])) { + foreach ($ghItem['choice_category_list'] as $ghChoiceCat) { + $ghModName = trim($ghChoiceCat['name'] ?? ''); + if (empty($ghModName)) continue; + $ghItemModifiers[] = $ghModName; + + if (!isset($ghModifierGroups[$ghModName])) { + $ghModOptions = []; + if (!empty($ghChoiceCat['choice_option_list'])) { + foreach ($ghChoiceCat['choice_option_list'] as $ghOpt) { + $optName = trim($ghOpt['description'] ?? ''); + $optPrice = !empty($ghOpt['price']['amount']) ? (float)$ghOpt['price']['amount'] / 100 : 0; + if (strlen($optName)) $ghModOptions[] = ['name' => $optName, 'price' => $optPrice]; + } + } + $ghMinSel = (int)($ghChoiceCat['min_choice_options'] ?? 0); + $ghMaxSel = (int)($ghChoiceCat['max_choice_options'] ?? 0); + $ghModifierGroups[$ghModName] = [ + 'name' => $ghModName, + 'required' => $ghMinSel > 0, + 'minSelections' => $ghMinSel, + 'maxSelections' => $ghMaxSel, + 'options' => $ghModOptions, + ]; + } + } + } + + $ghItems[] = [ + 'id' => 'item_' . $ghItemId, + 'name' => $ghItemName, + 'price' => $ghPrice, + 'description' => $ghDesc, + 'category' => $ghCatName, + 'imageUrl' => $ghImageUrl, + 'hasModifiers' => count($ghItemModifiers) > 0, + 'modifiers' => $ghItemModifiers, + ]; + if (strlen($ghImageUrl)) $ghImageMappings[] = ['itemId' => 'item_' . $ghItemId, 'url' => $ghImageUrl]; + $ghCatItemCount++; + $ghItemId++; + } + } + $ghCategories[] = ['name' => $ghCatName, 'itemCount' => $ghCatItemCount]; + } + } + + $ghModifiers = array_values($ghModifierGroups); + $response['steps'][] = "Parsed " . count($ghItems) . " items in " . count($ghCategories) . " categories with " . count($ghModifiers) . " modifier groups"; + + $response['OK'] = true; + $response['DATA'] = [ + 'business' => $ghBusiness, + 'categories' => $ghCategories, + 'items' => $ghItems, + 'modifiers' => $ghModifiers, + 'imageUrls' => [], + 'imageMappings' => $ghImageMappings, + 'headerCandidateIndices' => [], + ]; + $response['sourceUrl'] = $targetUrl; + $response['pagesProcessed'] = 1; + $response['imagesFound'] = count($ghImageMappings); + $response['parsedVia'] = 'grubhub_api'; + jsonResponse($response); + } + // ========== END GRUBHUB FAST PATH ========== + + // Check if this is a local temp file (ZIP upload) - read directly + if (stripos($targetUrl, '/temp/menu-import/') !== false) { + $localUrlPath = preg_replace('#https?://[^/]+(/temp/menu-import/.*)#i', '$1', $targetUrl); + $localFilePath = $expandPath($localUrlPath); + $response['steps'][] = "Local temp file detected: $localFilePath"; + + if (!file_exists($localFilePath)) { + throw new Exception("Local file not found: $localFilePath"); + } + + $pageHtml = file_get_contents($localFilePath); + $playwrightImages = []; + $response['steps'][] = "Read " . strlen($pageHtml) . " bytes from local file"; + + $localDir = dirname($localFilePath); + $basePath = preg_replace('#/[^/]*$#', '/', $targetUrl); + + // Check for Toast menu page - extract from visible HTML + if (stripos($pageHtml, 'class="headerText"') !== false && stripos($pageHtml, 'toasttab') !== false) { + $response['steps'][] = "Toast menu detected - parsing visible HTML items"; + + try { + $toastBusiness = []; + $toastCategories = []; + $toastItems = []; + $categorySet = []; + $itemNameSet = []; + $itemId = 1; + + // Find category headers + if (preg_match_all('#]*class="[^"]*groupHeader[^"]*"[^>]*>([^<]+)#i', $pageHtml, $catMatches)) { + foreach ($catMatches[1] as $catName) { + $catName = trim($catName); + if (strlen($catName) && !isset($categorySet[$catName])) { + $categorySet[$catName] = true; + $toastCategories[] = ['name' => $catName, 'itemCount' => 0]; + } + } + } + + // Extract item blocks + if (preg_match_all('#]*class="[^"]*item[^"]*"[^>]*>.*?#is', $pageHtml, $blockMatches)) { + $response['steps'][] = "Found " . count($blockMatches[0]) . " item blocks in HTML"; + foreach ($blockMatches[0] as $block) { + if (preg_match('#([^<]+)#i', $block, $nm)) { + $itemName = trim($nm[1]); + if (strlen($itemName) && !isset($itemNameSet[$itemName])) { + $itemNameSet[$itemName] = true; + $itemStruct = ['id' => 'item_' . $itemId, 'name' => $itemName, 'modifiers' => [], 'price' => 0, 'description' => '']; + + // Price + if (preg_match('#\$([0-9]+\.?[0-9]*)#', $block, $pm)) { + $p = (float)$pm[1]; + if ($p > 0) $itemStruct['price'] = $p; + } + + // Description + if (preg_match('#]*class="[^"]*description[^"]*"[^>]*>([^<]+)#i', $block, $dm)) { + $itemStruct['description'] = trim($dm[1]); + } + + // Image + if (preg_match('#src="(Menu_files/[^"]+)"#i', $block, $im)) { + $itemStruct['imageUrl'] = $basePath . $im[1]; + $itemStruct['imageSrc'] = $basePath . $im[1]; + $itemStruct['imageFilename'] = basename($im[1]); + } + + $itemStruct['category'] = !empty($toastCategories) ? $toastCategories[0]['name'] : 'Menu'; + $toastItems[] = $itemStruct; + $itemId++; + } + } + } + } + + // Fallback: simpler headerText extraction + if (empty($toastItems)) { + if (preg_match_all('#([^<]+)#i', $pageHtml, $nameMatches)) { + foreach ($nameMatches[1] as $nm) { + $nm = trim($nm); + if (strlen($nm) && !isset($itemNameSet[$nm])) { + $itemNameSet[$nm] = true; + $toastItems[] = ['id' => 'item_' . $itemId, 'name' => $nm, 'price' => 0, 'description' => '', 'category' => 'Menu', 'modifiers' => []]; + $itemId++; + } + } + } + } + + // Try business name from title + if (preg_match('#]*>([^<]+)#i', $pageHtml, $tm)) { + $titleText = trim($tm[1]); + if (strpos($titleText, '|') !== false) $titleText = trim(explode('|', $titleText)[0]); + $titleText = preg_replace('#\s*-\s*(Menu|Order|Online).*$#i', '', $titleText); + if (strlen($titleText) && !isset($toastBusiness['name'])) { + $toastBusiness['name'] = $titleText; + } + } + + // Try og:title/og:site_name + if (empty($toastBusiness['name'])) { + if (preg_match('#]*property=["\']og:(site_name|title)["\'][^>]*content=["\']([^"\']+)["\']#i', $pageHtml, $ogm)) { + $ogText = trim($ogm[2]); + if (strpos($ogText, '|') !== false) $ogText = trim(explode('|', $ogText)[0]); + if (strlen($ogText)) $toastBusiness['name'] = $ogText; + } elseif (preg_match('#]*content=["\']([^"\']+)["\'][^>]*property=["\']og:(site_name|title)["\']#i', $pageHtml, $ogm)) { + $ogText = trim($ogm[1]); + if (strpos($ogText, '|') !== false) $ogText = trim(explode('|', $ogText)[0]); + if (strlen($ogText)) $toastBusiness['name'] = $ogText; + } + } + + // Try header element + if (empty($toastBusiness['name'])) { + if (preg_match('#<(?:h1|div)[^>]*class="[^"]*(?:restaurant|location|brand)[^"]*"[^>]*>([^<]+)<#i', $pageHtml, $hm)) { + $ht = trim($hm[1]); + if (strlen($ht) && strlen($ht) < 100) $toastBusiness['name'] = $ht; + } + } + + // Try first h1 + if (empty($toastBusiness['name'])) { + if (preg_match('#]*>([^<]+)#i', $pageHtml, $h1m)) { + $h1t = trim($h1m[1]); + if (strlen($h1t) && strlen($h1t) < 100) $toastBusiness['name'] = $h1t; + } + } + + // Try address from HTML + if (empty($toastBusiness['addressLine1'])) { + if (preg_match('#<[^>]*class="[^"]*address[^"]*"[^>]*>([^<]+)]+>#i', $pageHtml, $am)) { + $at = trim($am[1]); + if (strlen($at) && strlen($at) < 200) $toastBusiness['addressLine1'] = $at; + } + } + + // Try phone from HTML + if (empty($toastBusiness['phone'])) { + if (preg_match('#(?:tel:|phone[^"]*">)\s*\(?(\d{3})\)?[-.\s]?(\d{3})[-.\s]?(\d{4})#i', $pageHtml, $phm)) { + $toastBusiness['phone'] = $phm[1] . '-' . $phm[2] . '-' . $phm[3]; + } + } + + // Check __OO_STATE__ for images, categories, prices, business info + if (stripos($pageHtml, 'window.__OO_STATE__') !== false) { + $ooJson = $extractOoState($pageHtml); + if ($ooJson !== null) { + try { + $ooState = json_decode($ooJson, true); + if (is_array($ooState)) { + $imageMap = []; + $itemCategoryMap = []; + $itemPriceMap = []; + + foreach ($ooState as $key => $val) { + // Restaurant info + if (str_starts_with($key, 'Restaurant:') && is_array($val)) { + if (!empty($val['name'])) $toastBusiness['name'] = $val['name']; + if (!empty($val['location']) && is_array($val['location'])) { + $loc = $val['location']; + if (!empty($loc['address1'])) $toastBusiness['addressLine1'] = $loc['address1']; + if (!empty($loc['city'])) $toastBusiness['city'] = $loc['city']; + if (!empty($loc['state'])) $toastBusiness['state'] = $loc['state']; + if (!empty($loc['zipCode'])) $toastBusiness['zip'] = $loc['zipCode']; + if (!empty($loc['phone'])) $toastBusiness['phone'] = $loc['phone']; + } + if (!empty($val['brandColor'])) $toastBusiness['brandColor'] = str_replace('#', '', $val['brandColor']); + } + + // Menu items + if (str_starts_with($key, 'Menu:') && is_array($val) && !empty($val['groups']) && is_array($val['groups'])) { + foreach ($val['groups'] as $group) { + $groupName = trim($group['name'] ?? ''); + if (strlen($groupName) && !isset($categorySet[$groupName])) { + $categorySet[$groupName] = true; + $toastCategories[] = ['name' => $groupName, 'itemCount' => 0]; + } + + // Check for subgroups + $subgroups = $group['subgroups'] ?? $group['children'] ?? $group['childGroups'] ?? []; + if (!empty($subgroups) && is_array($subgroups)) { + foreach ($subgroups as $sg) { + $sgName = trim($sg['name'] ?? ''); + if (strlen($sgName) && !isset($categorySet[$sgName])) { + $categorySet[$sgName] = true; + $toastCategories[] = ['name' => $sgName, 'parentCategoryName' => $groupName, 'itemCount' => 0]; + } + if (!empty($sg['items']) && is_array($sg['items'])) { + $effectiveName = strlen($sgName) ? $sgName : $groupName; + foreach ($sg['items'] as $item) { + if (!empty($item['name'])) { + $itemCategoryMap[$item['name']] = $effectiveName; + $p = $extractToastPrice($item); + if ($p > 0) $itemPriceMap[$item['name']] = $p; + $img = $extractToastImage($item); + if (strlen($img)) $imageMap[$item['name']] = $img; + } + } + } + } + } + + // Direct items + if (!empty($group['items']) && is_array($group['items'])) { + foreach ($group['items'] as $item) { + if (!empty($item['name'])) { + if (strlen($groupName)) $itemCategoryMap[$item['name']] = $groupName; + $p = $extractToastPrice($item); + if ($p > 0) $itemPriceMap[$item['name']] = $p; + $img = $extractToastImage($item); + if (strlen($img)) $imageMap[$item['name']] = $img; + } + } + } + } + } + } + + // Apply to items + $imagesMatched = $categoriesMatched = $pricesMatched = 0; + for ($i = 0; $i < count($toastItems); $i++) { + $name = $toastItems[$i]['name']; + if (isset($imageMap[$name])) { + $toastItems[$i]['imageUrl'] = $imageMap[$name]; + $toastItems[$i]['imageSrc'] = $imageMap[$name]; + $toastItems[$i]['imageFilename'] = basename($imageMap[$name]); + $imagesMatched++; + } + if (isset($itemCategoryMap[$name])) { + $toastItems[$i]['category'] = $itemCategoryMap[$name]; + $categoriesMatched++; + } + if (isset($itemPriceMap[$name]) && ($toastItems[$i]['price'] ?? 0) == 0) { + $toastItems[$i]['price'] = $itemPriceMap[$name]; + $pricesMatched++; + } + } + $response['steps'][] = "Matched $imagesMatched images, $categoriesMatched categories, $pricesMatched prices from __OO_STATE__"; + } + } catch (Exception $e) { + // OO_STATE parse failed, continue + } + } + } + + // Default category if none + if (!empty($toastItems) && empty($toastCategories)) { + $toastCategories[] = ['name' => 'Menu', 'itemCount' => count($toastItems)]; + } + + // Scan ALL HTML files in the ZIP for business info + $extractUrlPath = preg_replace('#https?://[^/]+(/temp/menu-import/[a-f0-9]+/).*#i', '$1', $targetUrl); + $extractDir = $expandPath($extractUrlPath); + try { + $allHtmlFiles = []; + $it = new RecursiveDirectoryIterator($extractDir, RecursiveDirectoryIterator::SKIP_DOTS); + $files = new RecursiveIteratorIterator($it); + foreach ($files as $file) { + if (preg_match('/\.html?$/i', $file->getFilename())) { + $allHtmlFiles[] = $file->getRealPath(); + } + } + $response['steps'][] = "Found " . count($allHtmlFiles) . " HTML files in ZIP"; + + foreach ($allHtmlFiles as $otherFile) { + if ($otherFile === $localFilePath) continue; + try { + $otherHtml = file_get_contents($otherFile); + + // Business name from title + if (empty($toastBusiness['name'])) { + if (preg_match('#]*>([^<]+)#i', $otherHtml, $otm)) { + $ot = trim($otm[1]); + if (strlen($ot) && !preg_match('#^(Menu|Home|About|Contact|Order|Online)$#i', $ot)) { + if (strpos($ot, '|') !== false) $ot = trim(explode('|', $ot)[0]); + $ot = preg_replace('#\s*-\s*(Menu|Order|Online).*$#i', '', $ot); + if (strlen($ot) && strlen($ot) < 100) $toastBusiness['name'] = $ot; + } + } + } + + // Address from other files + if (empty($toastBusiness['addressLine1'])) { + if (preg_match('#(\d+\s+[A-Za-z0-9\s]+(?:St(?:reet)?|Ave(?:nue)?|Rd|Road|Blvd|Boulevard|Dr(?:ive)?|Ln|Lane|Way|Ct|Court|Pl(?:ace)?|Pkwy|Parkway)[.,]?\s*(?:Suite|Ste|#|Unit|Apt)?\s*[A-Za-z0-9\-]*)#i', $otherHtml, $adm)) { + $at = trim($adm[1]); + if (strlen($at) > 5 && strlen($at) < 100) $toastBusiness['addressLine1'] = $at; + } + } + + // Phone from other files + if (empty($toastBusiness['phone'])) { + if (preg_match('#\(?(\d{3})\)?[-.\s]?(\d{3})[-.\s]?(\d{4})#', $otherHtml, $phm)) { + $toastBusiness['phone'] = $phm[1] . '-' . $phm[2] . '-' . $phm[3]; + } + } + + // Check __OO_STATE__ in other files + if (stripos($otherHtml, 'window.__OO_STATE__') !== false) { + $otherOoJson = $extractOoState($otherHtml); + if ($otherOoJson !== null) { + try { + $otherOo = json_decode($otherOoJson, true); + if (is_array($otherOo)) { + foreach ($otherOo as $oKey => $oVal) { + if (str_starts_with($oKey, 'Restaurant:') && is_array($oVal)) { + if (!empty($oVal['name']) && empty($toastBusiness['name'])) $toastBusiness['name'] = $oVal['name']; + if (!empty($oVal['location']) && is_array($oVal['location'])) { + $ol = $oVal['location']; + if (!empty($ol['address1']) && empty($toastBusiness['addressLine1'])) $toastBusiness['addressLine1'] = $ol['address1']; + if (!empty($ol['city']) && empty($toastBusiness['city'])) $toastBusiness['city'] = $ol['city']; + if (!empty($ol['state']) && empty($toastBusiness['state'])) $toastBusiness['state'] = $ol['state']; + if (!empty($ol['zipCode']) && empty($toastBusiness['zip'])) $toastBusiness['zip'] = $ol['zipCode']; + if (!empty($ol['phone']) && empty($toastBusiness['phone'])) $toastBusiness['phone'] = $ol['phone']; + } + if (!empty($oVal['brandColor']) && empty($toastBusiness['brandColor'])) $toastBusiness['brandColor'] = str_replace('#', '', $oVal['brandColor']); + } + } + } + } catch (Exception $e) { /* skip */ } + } + } + } catch (Exception $e) { /* skip unreadable files */ } + } + } catch (Exception $e) { + $response['steps'][] = "Could not scan other HTML files: " . $e->getMessage(); + } + + $response['steps'][] = "Extracted " . count($toastItems) . " unique items from " . count($toastCategories) . " categories"; + + // Scan ZIP images and analyze for business info via Claude + try { + $zipImageFiles = []; + $it = new RecursiveDirectoryIterator($extractDir, RecursiveDirectoryIterator::SKIP_DOTS); + $files = new RecursiveIteratorIterator($it); + $imageExtensions = ['jpg','jpeg','png','gif','webp']; + foreach ($files as $file) { + if (!$file->isFile()) continue; + $ext = strtolower(pathinfo($file->getFilename(), PATHINFO_EXTENSION)); + if (in_array($ext, $imageExtensions) && $file->getSize() > 10000 && stripos($file->getPath(), '_files') === false) { + $zipImageFiles[] = $file->getRealPath(); + } + } + + if (!empty($zipImageFiles)) { + $response['steps'][] = "Found " . count($zipImageFiles) . " images in ZIP to analyze for business info"; + $imgLimit = min(count($zipImageFiles), 3); + for ($imgIdx = 0; $imgIdx < $imgLimit; $imgIdx++) { + try { + $imgContent = file_get_contents($zipImageFiles[$imgIdx]); + $base64Img = base64_encode($imgContent); + $mediaType = $detectMediaType($base64Img); + + $imgRequest = [ + 'model' => 'claude-sonnet-4-20250514', + 'max_tokens' => 1024, + 'temperature' => 0, + 'messages' => [[ + 'role' => 'user', + 'content' => [ + ['type' => 'image', 'source' => ['type' => 'base64', 'media_type' => $mediaType, 'data' => $base64Img]], + ['type' => 'text', 'text' => 'Extract ALL business information visible in this image. Look carefully for: 1) Business NAME (the restaurant/store name), 2) PHONE number (format: xxx-xxx-xxxx), 3) Full ADDRESS (street, city, state, zip), 4) HOURS of operation (all days shown). Return JSON: {"name":"","addressLine1":"","city":"","state":"","zip":"","phone":"","hours":"","brandColor":""}. For hours, format as single string like \'Mon-Thu 7am-10pm, Fri-Sat 7am-11pm\'. Return ONLY valid JSON.'], + ], + ]], + ]; + + $imgResp = $httpPost( + 'https://api.anthropic.com/v1/messages', + json_encode($imgRequest), + ['Content-Type: application/json', "x-api-key: $CLAUDE_API_KEY", 'anthropic-version: 2023-06-01'], + 60 + ); + + if ($imgResp['code'] === 200) { + $imgData = json_decode($imgResp['body'], true); + if (!empty($imgData['content'][0]['text'])) { + $imgText = $cleanClaudeJson($imgData['content'][0]['text']); + $imgBiz = json_decode($imgText, true); + if (is_array($imgBiz)) { + foreach (['name','addressLine1','city','state','zip','phone','hours','brandColor'] as $field) { + if (!empty($imgBiz[$field]) && is_scalar($imgBiz[$field])) { + $toastBusiness[$field] = trim($imgBiz[$field]); + } + } + } + } + } + } catch (Exception $e) { + $response['steps'][] = "Error analyzing image: " . $e->getMessage(); + } + } + } + } catch (Exception $e) { + $response['steps'][] = "Could not scan ZIP for images: " . $e->getMessage(); + } + + // Return directly + $response['OK'] = true; + $response['DATA'] = [ + 'business' => $toastBusiness, + 'categories' => $toastCategories, + 'modifiers' => [], + 'items' => $toastItems, + 'imageUrls' => [], + 'headerCandidateIndices' => [], + 'imageMappings' => [], + ]; + $response['sourceUrl'] = $targetUrl; + $response['pagesProcessed'] = 1; + $response['imagesFound'] = 0; + $response['playwrightImagesCount'] = 0; + $response['toastDirect'] = true; + jsonResponse($response); + + } catch (Exception $e) { + $response['steps'][] = "Toast HTML parse failed: " . $e->getMessage() . " - falling back to Claude"; + } + } + + // Extract base URL for relative links (local temp file case) + if (preg_match('#^(https?://[^/]+)#', $targetUrl, $bm)) { + $baseUrl = $bm[1]; + } + $basePath = preg_replace('#/[^/]*$#', '/', preg_replace('#\?.*$#', '', $targetUrl)); + + } else { + // Remote URL - use Playwright for JS-rendered content + $response['steps'][] = "Fetching URL with Playwright: $targetUrl"; + + $pwOutput = shell_exec("/opt/playwright/run.sh " . escapeshellarg($targetUrl) . " 5000 2>&1"); + if (empty(trim($pwOutput ?? ''))) { + throw new Exception("Playwright returned empty response"); + } + + $pwResult = json_decode($pwOutput, true); + if (isset($pwResult['error'])) { + throw new Exception("Playwright error: " . $pwResult['error']); + } + + $pageHtml = $pwResult['html'] ?? ''; + $playwrightImages = $pwResult['images'] ?? []; + $response['steps'][] = "Fetched " . strlen($pageHtml) . " bytes via Playwright, " . count($playwrightImages) . " images captured"; + + // ========== WOOCOMMERCE FAST PATH ========== + if (stripos($pageHtml, 'woocommerce') !== false || stripos($pageHtml, 'wc-add-to-cart') !== false || stripos($pageHtml, 'tm-extra-product-options') !== false) { + $response['steps'][] = "WooCommerce site detected - running modifier extraction"; + $wooUrl = preg_replace('#(https?://[^/]+).*#', '$1', $targetUrl); + + try { + $wooOutput = shell_exec("/opt/playwright/run-woo-modifiers.sh " . escapeshellarg($wooUrl) . " 2>&1"); + if (!empty(trim($wooOutput ?? ''))) { + $wooResult = json_decode($wooOutput, true); + if (!empty($wooResult['items']) && is_array($wooResult['items'])) { + $response['steps'][] = "WooCommerce extraction: " . count($wooResult['items']) . " items, " . count($wooResult['modifiers'] ?? []) . " modifier groups"; + + $wooCats = []; + $wooItems = []; + foreach ($wooResult['items'] as $wi => $wItem) { + $catName = !empty($wItem['category']) ? trim($wItem['category']) : 'Menu'; + if (!isset($wooCats[$catName])) $wooCats[$catName] = 0; + $wooCats[$catName]++; + + $itemMods = $wooResult['itemModifierMap'][$wItem['name']] ?? []; + $wooItems[] = [ + 'id' => 'item_' . ($wi + 1), + 'name' => $wItem['name'], + 'price' => (float)($wItem['price'] ?? 0), + 'description' => $wItem['description'] ?? '', + 'category' => $catName, + 'modifiers' => $itemMods, + 'hasModifiers' => count($itemMods) > 0, + 'imageUrl' => trim($wItem['imageUrl'] ?? ''), + ]; + } + + $wooCategories = []; + foreach ($wooCats as $wcName => $wcCount) { + $wooCategories[] = ['name' => $wcName, 'itemCount' => $wcCount]; + } + + $wooBiz = $wooResult['business'] ?? []; + $response['OK'] = true; + $response['DATA'] = [ + 'business' => [ + 'name' => $wooBiz['name'] ?? '', + 'address' => $wooBiz['address'] ?? '', + 'phone' => $wooBiz['phone'] ?? '', + 'hours' => $wooBiz['hours'] ?? '', + ], + 'categories' => $wooCategories, + 'items' => $wooItems, + 'modifiers' => $wooResult['modifiers'] ?? [], + 'imageUrls' => [], + 'imageMappings' => [], + 'headerCandidateIndices' => [], + ]; + $response['sourceUrl'] = $targetUrl; + $response['parsedVia'] = 'woocommerce_playwright'; + jsonResponse($response); + } + } + $response['steps'][] = "WooCommerce extraction returned no items - falling through to Claude"; + } catch (Exception $e) { + $response['steps'][] = "WooCommerce extraction failed: " . $e->getMessage() . " - falling through to Claude"; + } + } + // ========== END WOOCOMMERCE FAST PATH ========== + + // ========== DOORDASH / ORDER.ONLINE FAST PATH ========== + if (stripos($pageHtml, 'MenuPageItem') !== false && stripos($pageHtml, 'MenuPageItemList') !== false) { + $response['steps'][] = "DoorDash/order.online site detected - extracting embedded data"; + try { + // Build image map from StorePageCarouselItem entries + $ddImageMap = []; + $carouselMarker = $BQ . '__typename' . $BQ . ':' . $BQ . 'StorePageCarouselItem' . $BQ; + $searchPos = 0; + while (true) { + $searchPos = stripos($pageHtml, $carouselMarker, $searchPos); + if ($searchPos === false) break; + $nextMarker = stripos($pageHtml, $BQ . '__typename' . $BQ, $searchPos + strlen($carouselMarker)); + if ($nextMarker === false) $nextMarker = strlen($pageHtml); + $entryText = substr($pageHtml, $searchPos, $nextMarker - $searchPos); + + $cpName = $extractBqValue($entryText, 'name'); + if ($cpName !== null) { + $cpImg = $extractBqValue($entryText, 'imgUrl'); + if ($cpImg !== null && $cpImg !== 'null' && stripos($cpImg, 'http') !== false) { + if (stripos($cpImg, 'width=') !== false) { + $cpImg = preg_replace('/width=\d+/i', 'width=600', $cpImg); + $cpImg = preg_replace('/height=\d+/i', 'height=600', $cpImg); + } + $ddImageMap[$cpName] = $cpImg; + } + } + $searchPos += strlen($carouselMarker); + } + $response['steps'][] = "Built image map with " . count($ddImageMap) . " entries from carousel"; + + // Extract menu from MenuPageItemList + $ddCategories = []; + $ddCatSeen = []; + $ddItems = []; + $ddItemSeen = []; + $ddItemCounter = 0; + + $catMarker = $BQ . '__typename' . $BQ . ':' . $BQ . 'MenuPageItemList' . $BQ; + $itemMarker = $BQ . '__typename' . $BQ . ':' . $BQ . 'MenuPageItem' . $BQ; + + $catPos = 0; + while (true) { + $catPos = stripos($pageHtml, $catMarker, $catPos); + if ($catPos === false) break; + + $nextCatPos = stripos($pageHtml, $catMarker, $catPos + strlen($catMarker)); + if ($nextCatPos === false) $nextCatPos = strlen($pageHtml); + $catSection = substr($pageHtml, $catPos, $nextCatPos - $catPos); + + $catName = $extractBqValue($catSection, 'name'); + if ($catName === null) { $catPos += strlen($catMarker); continue; } + $catName = str_replace(['\\u0026', '&'], '&', $catName); + + if ($catName === 'Most Ordered' || isset($ddCatSeen[$catName])) { + $catPos += strlen($catMarker); + continue; + } + $ddCatSeen[$catName] = true; + $ddCategories[] = ['name' => $catName, 'parentCategoryName' => '']; + + // Items within category + $itemPos = 0; + while (true) { + $itemPos = stripos($catSection, $itemMarker, $itemPos); + if ($itemPos === false) break; + $nextItemPos = stripos($catSection, $itemMarker, $itemPos + strlen($itemMarker)); + if ($nextItemPos === false) $nextItemPos = strlen($catSection); + $itemEntry = substr($catSection, $itemPos, $nextItemPos - $itemPos); + + $ddItemId = $extractBqValue($itemEntry, 'id') ?? ''; + $ipName = $extractBqValue($itemEntry, 'name'); + if ($ipName === null) { $itemPos += strlen($itemMarker); continue; } + $ipName = str_replace('\\u0026', '&', $ipName); + if (isset($ddItemSeen[$ipName])) { $itemPos += strlen($itemMarker); continue; } + $ddItemSeen[$ipName] = true; + + $ipDesc = $extractBqValue($itemEntry, 'description') ?? ''; + $ipDesc = str_replace('\\u0026', '&', $ipDesc); + + $ipPriceStr = $extractBqValue($itemEntry, 'displayPrice') ?? ''; + $ipPrice = (float)preg_replace('/[^0-9.]/', '', $ipPriceStr); + + // Image from carousel map or item entry + $ipImg = $ddImageMap[$ipName] ?? ''; + if (empty($ipImg)) { + $ipImg = $extractBqValue($itemEntry, 'imageUrl') ?? ''; + if ($ipImg === 'null' || stripos($ipImg, 'http') === false) $ipImg = ''; + if (strlen($ipImg) && stripos($ipImg, 'width=') !== false) { + $ipImg = preg_replace('/width=\d+/i', 'width=600', $ipImg); + $ipImg = preg_replace('/height=\d+/i', 'height=600', $ipImg); + } + } + + $ddItemCounter++; + $ddItem = [ + 'name' => $ipName, + 'description' => $ipDesc, + 'price' => $ipPrice, + 'category' => $catName, + 'modifiers' => [], + 'id' => 'item_' . $ddItemCounter, + 'ddItemId' => $ddItemId, + 'imageUrl' => $ipImg, + 'imageSrc' => $ipImg, + ]; + if (strlen($ipImg)) $ddItem['imageFilename'] = basename(parse_url($ipImg, PHP_URL_PATH) ?: $ipImg); + $ddItems[] = $ddItem; + + $itemPos += strlen($itemMarker); + } + $catPos += strlen($catMarker); + } + + $ddItemsWithImg = 0; + foreach ($ddItems as $ddi) { if (!empty($ddi['imageUrl'])) $ddItemsWithImg++; } + $response['steps'][] = "Found " . count($ddCategories) . " categories, " . count($ddItems) . " items ($ddItemsWithImg with images)"; + + // Extract business info + $ddBusiness = []; + if (preg_match('#([^<]+)#i', $pageHtml, $ddTm)) { + $ddTitle = preg_replace('#\s*[-|].*#', '', trim($ddTm[1])); + if (strlen($ddTitle)) $ddBusiness['name'] = $ddTitle; + } + + // Address from StoreHeaderAddress + $ddAddrMarker = $BQ . '__typename' . $BQ . ':' . $BQ . 'StoreHeaderAddress' . $BQ; + $ddAddrPos = stripos($pageHtml, $ddAddrMarker); + if ($ddAddrPos !== false) { + $ddAddrEnd = stripos($pageHtml, $BQ . '__typename' . $BQ, $ddAddrPos + strlen($ddAddrMarker)); + if ($ddAddrEnd === false) $ddAddrEnd = min($ddAddrPos + 2000, strlen($pageHtml)); + $ddAddrSection = substr($pageHtml, $ddAddrPos, $ddAddrEnd - $ddAddrPos); + $street = $extractBqValue($ddAddrSection, 'street'); + if ($street !== null) $ddBusiness['street'] = $street; + $displayAddr = $extractBqValue($ddAddrSection, 'displayAddress'); + if ($displayAddr !== null) $ddBusiness['address'] = $displayAddr; + } + + // Phone from StoreHeaderPhoneNumber + $ddPhoneMarker = $BQ . '__typename' . $BQ . ':' . $BQ . 'StoreHeaderPhoneNumber' . $BQ; + $ddPhonePos = stripos($pageHtml, $ddPhoneMarker); + if ($ddPhonePos !== false) { + $ddPhoneEnd = stripos($pageHtml, $BQ . '__typename' . $BQ, $ddPhonePos + strlen($ddPhoneMarker)); + if ($ddPhoneEnd === false) $ddPhoneEnd = min($ddPhonePos + 1000, strlen($pageHtml)); + $ddPhoneSection = substr($pageHtml, $ddPhonePos, $ddPhoneEnd - $ddPhonePos); + $phone = $extractBqValue($ddPhoneSection, 'phoneNumber'); + if ($phone !== null) $ddBusiness['phone'] = $phone; + } + + // Hours from StoreOperationHoursRange + $ddHoursMarker = $BQ . '__typename' . $BQ . ':' . $BQ . 'StoreOperationHoursRange' . $BQ; + if (stripos($pageHtml, $ddHoursMarker) !== false) { + $ddHoursArr = []; + $hPos = 0; + while (true) { + $hPos = stripos($pageHtml, $ddHoursMarker, $hPos); + if ($hPos === false) break; + $hNext = stripos($pageHtml, $ddHoursMarker, $hPos + strlen($ddHoursMarker)); + if ($hNext === false) $hNext = min($hPos + 500, strlen($pageHtml)); + $hSection = substr($pageHtml, $hPos, $hNext - $hPos); + + $dayRange = $extractBqValue($hSection, 'dayRange'); + $timeRange = $extractBqValue($hSection, 'timeRange'); + if ($dayRange !== null && $timeRange !== null) { + $ddHoursArr[] = "$dayRange: $timeRange"; + } + $hPos += strlen($ddHoursMarker); + } + if (!empty($ddHoursArr)) $ddBusiness['hours'] = implode('; ', $ddHoursArr); + } + + if (!empty($ddItems)) { + // Playwright modifier extraction + $ddModifiers = []; + $ddItemModMap = []; + try { + $response['steps'][] = "Running stealth Playwright for modifier extraction..."; + $ddItemsForPw = []; + foreach ($ddItems as $ddi) { + $ddItemsForPw[] = ['id' => $ddi['ddItemId'], 'name' => $ddi['name']]; + } + $ddTempFile = '/tmp/dd-items-' . generateUUID() . '.json'; + file_put_contents($ddTempFile, json_encode($ddItemsForPw)); + + $modTimeout = 180 + count($ddItems) * 2; + if ($modTimeout > 600) $modTimeout = 600; + $ddModOutput = shell_exec("/opt/playwright/run-doordash-modifiers.sh " . escapeshellarg($targetUrl) . " " . escapeshellarg($ddTempFile) . " 2>&1"); + @unlink($ddTempFile); + + if (!empty(trim($ddModOutput ?? ''))) { + $ddModData = json_decode(trim($ddModOutput), true); + if (!empty($ddModData['modifiers']) && is_array($ddModData['modifiers'])) { + $ddModifiers = $ddModData['modifiers']; + foreach ($ddModifiers as &$ddMod) { + $ddMod['type'] = (!empty($ddMod['maxSelections']) && $ddMod['maxSelections'] == 1) ? 'select' : 'checkbox'; + } + unset($ddMod); + } + if (!empty($ddModData['itemModifierMap']) && is_array($ddModData['itemModifierMap'])) { + $ddItemModMap = $ddModData['itemModifierMap']; + for ($i = 0; $i < count($ddItems); $i++) { + if (isset($ddItemModMap[$ddItems[$i]['name']])) { + $ddItems[$i]['modifiers'] = $ddItemModMap[$ddItems[$i]['name']]; + } + } + } + $response['steps'][] = "Modifier extraction: " . count($ddModifiers) . " groups, " . count($ddItemModMap) . " items mapped"; + } + } catch (Exception $e) { + $response['steps'][] = "Modifier extraction failed (non-fatal): " . $e->getMessage(); + } + + $ddImageUrls = []; + foreach ($ddItems as $ddI) { + if (!empty($ddI['imageUrl'])) $ddImageUrls[] = $ddI['imageUrl']; + } + + $response['OK'] = true; + $response['DATA'] = [ + 'business' => $ddBusiness, + 'categories' => $ddCategories, + 'modifiers' => $ddModifiers, + 'items' => $ddItems, + 'imageUrls' => $ddImageUrls, + 'headerCandidateIndices' => [], + ]; + $response['sourceUrl'] = $targetUrl; + $response['parsedVia'] = 'doordash_embedded'; + $response['imagesFound'] = count($ddImageUrls); + $response['playwrightImagesCount'] = count($playwrightImages); + jsonResponse($response); + } + } catch (Exception $e) { + $response['steps'][] = "DoorDash extraction failed: " . $e->getMessage() . " - falling through to Claude"; + } + } + // ========== END DOORDASH FAST PATH ========== + + // Extract base URL for relative links + if (preg_match('#^(https?://[^/]+)#', $targetUrl, $bm)) { + $baseUrl = $bm[1]; + } + $basePath = preg_replace('#\?.*$#', '', $targetUrl); + if (!preg_match('#/$#', $basePath)) { + $basePath = preg_replace('#/[^/]*$#', '/', $basePath); + } + } + } else { + throw new Exception("Either 'url' or 'html' content is required"); + } + + // Menu pages array + $menuPages = [['url' => !empty($targetUrl) ? $targetUrl : 'uploaded', 'html' => $pageHtml]]; + + // Extract images from all pages + $imageUrls = []; + $imageMappings = []; + + // Add Playwright-captured images + foreach ($playwrightImages as $pwImg) { + if (!preg_match('#(icon|favicon|logo|sprite|pixel|tracking|badge|button|\.svg)#i', $pwImg)) { + $imageUrls[$pwImg] = true; + } + } + + foreach ($menuPages as $menuPage) { + if (preg_match_all('#]+src=["\']([^"\']+)["\'][^>]*>#i', $menuPage['html'], $imgMatches, PREG_SET_ORDER)) { + foreach ($imgMatches as $imgMatch) { + $imgTag = $imgMatch[0]; + $imgSrc = $imgMatch[1]; + + // Extract alt text + $imgAlt = ''; + if (preg_match('#alt=["\']([^"\']+)["\']#i', $imgTag, $altM)) { + $imgAlt = $altM[1]; + } + + // Image mapping for local uploads + $imgFilename = basename($imgSrc); + if (strlen($imgFilename) && strlen($imgAlt) && !preg_match('#(icon|favicon|logo|sprite|pixel|tracking|badge|button)#i', $imgSrc)) { + $imageMappings[] = ['filename' => $imgFilename, 'alt' => $imgAlt, 'src' => $imgSrc]; + } + + // Resolve relative URLs + if (str_starts_with($imgSrc, '/')) { + $imgSrc = $baseUrl . $imgSrc; + } elseif (!preg_match('#^https?://#i', $imgSrc) && !str_starts_with($imgSrc, 'data:')) { + $imgSrc = $basePath . $imgSrc; + } + + if (preg_match('#^https?://#i', $imgSrc) && !isset($imageUrls[$imgSrc])) { + if (!preg_match('#(icon|favicon|logo|sprite|pixel|tracking|badge|button)#i', $imgSrc)) { + $imageUrls[$imgSrc] = true; + } + } + } + } + } + + $response['steps'][] = "Found " . count($imageUrls) . " unique images"; + + // Check for local scan (ZIP upload) + $isLocalScan = !empty($targetUrl) && stripos($targetUrl, '/temp/menu-import/') !== false; + $localBasePath = ''; + if ($isLocalScan) { + $localUrlPath = preg_replace('#https?://[^/]+(/temp/menu-import/[^/]+/).*#i', '$1', $targetUrl); + $localBasePath = $expandPath($localUrlPath); + $response['steps'][] = "Local scan detected, base path: $localBasePath"; + } + + // Download/read images (limit to 20) + $imageDataArray = []; + $downloadedCount = 0; + $localReadCount = 0; + + foreach (array_keys($imageUrls) as $imgUrl) { + if ($downloadedCount >= 20) break; + try { + $imgBytes = 0; + $imgContent = ''; + $mediaType = 'image/jpeg'; + + if ($isLocalScan && stripos($imgUrl, '/temp/menu-import/') !== false) { + $localPath = $expandPath(preg_replace('#https?://[^/]+(/temp/menu-import/.*)#i', '$1', $imgUrl)); + if (file_exists($localPath)) { + $imgContent = file_get_contents($localPath); + $imgBytes = strlen($imgContent); + $ext = strtolower(pathinfo($localPath, PATHINFO_EXTENSION)); + if ($ext === 'png') $mediaType = 'image/png'; + elseif ($ext === 'gif') $mediaType = 'image/gif'; + elseif ($ext === 'webp') $mediaType = 'image/webp'; + $localReadCount++; + } + } else { + $result = $httpGet($imgUrl, [], 10); + if ($result['code'] === 200 && !empty($result['body'])) { + $ct = $result['contentType']; + if (preg_match('#image/(jpeg|jpg|png|gif|webp)#i', $ct)) { + $imgContent = $result['body']; + $imgBytes = strlen($imgContent); + if (stripos($ct, 'png') !== false) $mediaType = 'image/png'; + elseif (stripos($ct, 'gif') !== false) $mediaType = 'image/gif'; + elseif (stripos($ct, 'webp') !== false) $mediaType = 'image/webp'; + } + } + } + + if ($imgBytes > 5000) { + $base64Content = base64_encode($imgContent); + $mediaType = $detectMediaType($base64Content); + + $imageDataArray[] = [ + 'type' => 'image', + 'source' => ['type' => 'base64', 'media_type' => $mediaType, 'data' => $base64Content], + 'url' => $imgUrl, + ]; + $downloadedCount++; + } + } catch (Exception $e) { + // Skip failed downloads + } + } + + $response['steps'][] = "Loaded " . count($imageDataArray) . " valid images ($localReadCount from local disk)"; + + // ============================================================ + // TOAST FAST PATH: Parse __OO_STATE__ directly instead of Claude + // ============================================================ + if (stripos($pageHtml, 'window.__OO_STATE__') !== false && stripos($pageHtml, 'toasttab') !== false) { + $response['steps'][] = "Toast page detected - extracting menu data from __OO_STATE__"; + try { + $ooJson = $extractOoState($pageHtml); + if ($ooJson !== null) { + $ooState = json_decode($ooJson, true); + if (!is_array($ooState)) throw new Exception("Failed to parse __OO_STATE__ JSON"); + + $toastBusiness = []; + $toastCategories = []; + $toastItems = []; + $categorySet = []; + $itemId = 1; + $menuNames = []; + + // Extract restaurant info from ROOT_QUERY + if (!empty($ooState['ROOT_QUERY']) && is_array($ooState['ROOT_QUERY'])) { + foreach ($ooState['ROOT_QUERY'] as $rqKey => $rqVal) { + if ((stripos($rqKey, 'restaurantV2By') !== false || stripos($rqKey, 'restaurantV2(') !== false) && is_array($rqVal)) { + if (!empty($rqVal['name']) && empty($toastBusiness['name'])) $toastBusiness['name'] = $rqVal['name']; + if (!empty($rqVal['description']) && strlen(trim((string)$rqVal['description']))) { + $toastBusiness['description'] = trim((string)$rqVal['description']); + } + if (!empty($rqVal['location']) && is_array($rqVal['location'])) { + $loc = $rqVal['location']; + if (!empty($loc['address1'])) { + $toastBusiness['addressLine1'] = $loc['address1']; + $toastBusiness['address'] = $loc['address1']; + if (!empty($loc['city'])) { $toastBusiness['city'] = $loc['city']; $toastBusiness['address'] .= ', ' . $loc['city']; } + if (!empty($loc['state'])) { $toastBusiness['state'] = $loc['state']; $toastBusiness['address'] .= ', ' . $loc['state']; } + $zip = $loc['zip'] ?? $loc['zipCode'] ?? null; + if (!empty($zip)) { $toastBusiness['zip'] = $zip; $toastBusiness['address'] .= ' ' . $zip; } + } + if (!empty($loc['phone'])) $toastBusiness['phone'] = $loc['phone']; + if (!empty($loc['latitude']) && is_numeric($loc['latitude']) && !empty($loc['longitude']) && is_numeric($loc['longitude'])) { + $toastBusiness['latitude'] = $loc['latitude']; + $toastBusiness['longitude'] = $loc['longitude']; + } + } + if (!empty($rqVal['brandColor'])) $toastBusiness['brandColor'] = str_replace('#', '', $rqVal['brandColor']); + + // Hours from schedule + if (!empty($rqVal['schedule']['upcomingSchedules'][0]['dailySchedules'])) { + $dayHours = []; + foreach ($rqVal['schedule']['upcomingSchedules'][0]['dailySchedules'] as $ds) { + if (!empty($ds['date']) && !empty($ds['servicePeriods'][0]['startTime'])) { + $dow = (int)date('w', strtotime($ds['date'])) + 1; // 1=Sun + $sp = $ds['servicePeriods'][0]; + $dayHours[$dow] = ['open' => substr($sp['startTime'], 0, 5), 'close' => substr($sp['endTime'], 0, 5)]; + } + } + $dayNames = [1=>'Sun',2=>'Mon',3=>'Tue',4=>'Wed',5=>'Thu',6=>'Fri',7=>'Sat']; + $dayOrder = [2,3,4,5,6,7,1]; // Mon-Sun + $hoursParts = []; + foreach ($dayOrder as $dIdx) { + if (isset($dayHours[$dIdx])) { + $dh = $dayHours[$dIdx]; + $op = explode(':', $dh['open']); + $cp = explode(':', $dh['close']); + $openStr = $formatTime12h((int)$op[0], (int)($op[1] ?? 0)); + $closeStr = $formatTime12h((int)$cp[0], (int)($cp[1] ?? 0)); + $hoursParts[] = $dayNames[$dIdx] . " $openStr-$closeStr"; + } + } + if (!empty($hoursParts)) $toastBusiness['hours'] = implode(', ', $hoursParts); + } + } + } + } + + // Also check Restaurant: keys (older format) + foreach ($ooState as $ooKey => $ooVal) { + if (str_starts_with($ooKey, 'Restaurant:') && empty($toastBusiness['name']) && is_array($ooVal)) { + if (!empty($ooVal['name'])) $toastBusiness['name'] = $ooVal['name']; + if (!empty($ooVal['location']) && is_array($ooVal['location'])) { + $loc = $ooVal['location']; + if (!empty($loc['address1'])) { + $toastBusiness['address'] = $loc['address1']; + if (!empty($loc['city'])) $toastBusiness['address'] .= ', ' . $loc['city']; + if (!empty($loc['state'])) $toastBusiness['address'] .= ', ' . $loc['state']; + if (!empty($loc['zipCode'])) $toastBusiness['address'] .= ' ' . $loc['zipCode']; + } + if (!empty($loc['phone'])) $toastBusiness['phone'] = $loc['phone']; + } + if (!empty($ooVal['brandColor'])) $toastBusiness['brandColor'] = str_replace('#', '', $ooVal['brandColor']); + } + + // Menu data + if (str_starts_with($ooKey, 'Menu:') && is_array($ooVal) && !empty($ooVal['groups']) && is_array($ooVal['groups'])) { + $menuName = $ooVal['name'] ?? ''; + if (strlen($menuName)) $menuNames[] = $menuName; + + foreach ($ooVal['groups'] as $group) { + $groupName = trim($group['name'] ?? 'Menu'); + if (!isset($categorySet[$groupName])) { + $categorySet[$groupName] = true; + $catObj = ['name' => $groupName, 'itemCount' => 0, 'menuName' => $menuName]; + $toastCategories[] = $catObj; + } + + // Items from group + if (!empty($group['items']) && is_array($group['items'])) { + foreach ($group['items'] as $item) { + if (empty($item['name']) || !strlen(trim($item['name']))) continue; + $itemStruct = [ + 'id' => 'item_' . $itemId, + 'name' => trim($item['name']), + 'category' => $groupName, + 'modifiers' => [], + 'hasModifiers' => !empty($item['hasModifiers']), + 'guid' => $item['guid'] ?? '', + 'itemGroupGuid' => $item['itemGroupGuid'] ?? '', + 'description' => isset($item['description']) && !is_null($item['description']) ? trim((string)$item['description']) : '', + 'price' => $extractToastPrice($item), + 'imageUrl' => '', + ]; + $img = $extractToastImage($item); + if (strlen($img)) { + $itemStruct['imageUrl'] = $img; + $itemStruct['imageSrc'] = $img; + $itemStruct['imageFilename'] = basename($img); + } + $toastItems[] = $itemStruct; + $itemId++; + } + } + + // Subgroups + $subgroups = $group['subgroups'] ?? $group['children'] ?? $group['childGroups'] ?? []; + if (!empty($subgroups) && is_array($subgroups)) { + foreach ($subgroups as $sg) { + $subName = trim($sg['name'] ?? $groupName); + if (strlen($subName) && !isset($categorySet[$subName])) { + $categorySet[$subName] = true; + $toastCategories[] = ['name' => $subName, 'parentCategoryName' => $groupName, 'itemCount' => 0]; + } + if (!empty($sg['items']) && is_array($sg['items'])) { + foreach ($sg['items'] as $subItem) { + if (empty($subItem['name']) || !strlen(trim($subItem['name']))) continue; + $itemStruct = [ + 'id' => 'item_' . $itemId, + 'name' => trim($subItem['name']), + 'category' => $subName, + 'modifiers' => [], + 'hasModifiers' => !empty($subItem['hasModifiers']), + 'guid' => $subItem['guid'] ?? '', + 'itemGroupGuid' => $subItem['itemGroupGuid'] ?? '', + 'description' => isset($subItem['description']) && !is_null($subItem['description']) ? trim((string)$subItem['description']) : '', + 'price' => $extractToastPrice($subItem), + 'imageUrl' => '', + ]; + $img = $extractToastImage($subItem); + if (strlen($img)) { + $itemStruct['imageUrl'] = $img; + $itemStruct['imageSrc'] = $img; + $itemStruct['imageFilename'] = basename($img); + } + $toastItems[] = $itemStruct; + $itemId++; + } + } + } + } + } + } + } + + // Fallback: business name from title + if (empty($toastBusiness['name'])) { + if (preg_match('#]*>([^<]+)#i', $pageHtml, $tm)) { + $titleText = trim($tm[1]); + if (strpos($titleText, '|') !== false) $titleText = trim(explode('|', $titleText)[0]); + $titleText = preg_replace('#\s*-\s*(Menu|Order|Online).*$#i', '', $titleText); + if (strlen($titleText)) $toastBusiness['name'] = $titleText; + } + } + + // Clean business name + if (!empty($toastBusiness['name'])) { + $bizName = $toastBusiness['name']; + $bizName = preg_replace('#\s*[-|]+\s*(Order\s+(pickup|online|delivery|food)|Online\s+Order|Delivery\s*[&and]+\s*Takeout|Takeout\s*[&and]+\s*Delivery|Menu\s*[&and]+\s*Order).*$#i', '', $bizName); + if (!empty($toastBusiness['addressLine1']) && stripos($bizName, $toastBusiness['addressLine1']) !== false) { + $bizName = trim(str_ireplace($toastBusiness['addressLine1'], '', $bizName)); + } + if (!empty($toastBusiness['address'])) { + $addrFirst = trim(explode(',', $toastBusiness['address'])[0]); + if (strlen($addrFirst) && stripos($bizName, $addrFirst) !== false) { + $bizName = trim(str_ireplace($addrFirst, '', $bizName)); + } + } + $bizName = trim(preg_replace('#[-|]+$#', '', trim($bizName))); + $bizName = trim(preg_replace('#^[-|]+#', '', $bizName)); + $toastBusiness['name'] = trim($bizName); + } + + // Clean city + if (!empty($toastBusiness['city']) && strpos($toastBusiness['city'], ',') !== false) { + $toastBusiness['city'] = trim(explode(',', $toastBusiness['city'])[0]); + } + + // Multi-menu hierarchy + if (count($menuNames) > 1) { + $hierarchicalCategories = []; + foreach ($menuNames as $mn) { + $hierarchicalCategories[] = ['name' => $mn, 'itemCount' => 0]; + foreach ($toastCategories as $tc) { + if (($tc['menuName'] ?? '') === $mn) { + $tc['parentCategoryName'] = $mn; + $hierarchicalCategories[] = $tc; + } + } + } + $toastCategories = $hierarchicalCategories; + } + + // Update category item counts + for ($ci = 0; $ci < count($toastCategories); $ci++) { + $count = 0; + foreach ($toastItems as $ti) { + if ($ti['category'] === $toastCategories[$ci]['name']) $count++; + } + $toastCategories[$ci]['itemCount'] = $count; + } + + $response['steps'][] = "Extracted " . count($toastItems) . " items from " . count($toastCategories) . " categories via __OO_STATE__"; + + // Toast modifier extraction via Playwright + $toastModifiers = []; + $modifierItemCount = 0; + foreach ($toastItems as $ti) { + if (!empty($ti['hasModifiers'])) $modifierItemCount++; + } + + if ($modifierItemCount > 0) { + $response['steps'][] = "$modifierItemCount items have modifiers - extracting via Playwright"; + try { + $toastUrl = ''; + if (!empty($targetUrl) && preg_match('#toasttab\.com#i', $targetUrl)) { + $toastUrl = $targetUrl; + } else { + // Try shortUrl from HTML + if (preg_match('#"shortUrl"\s*:\s*"([^"]+)"#i', $pageHtml, $sm)) { + $toastUrl = 'https://www.toasttab.com/local/order/' . $sm[1]; + } + if (empty($toastUrl) && preg_match('#toasttab\.com/([a-zA-Z0-9_-]+)/giftcards#i', $pageHtml, $gm)) { + $toastUrl = 'https://www.toasttab.com/local/order/' . $gm[1]; + } + } + + if (strlen($toastUrl)) { + $response['steps'][] = "Fetching modifiers from: $toastUrl"; + $modOutput = shell_exec("/opt/playwright/run-toast-modifiers.sh " . escapeshellarg($toastUrl) . " 2>&1"); + + if (!empty(trim($modOutput ?? ''))) { + $modResult = json_decode($modOutput, true); + if (!empty($modResult['modifiers']) && is_array($modResult['modifiers'])) { + $toastModifiers = $modResult['modifiers']; + $response['steps'][] = "Extracted " . count($toastModifiers) . " unique modifier groups"; + } + if (!empty($modResult['itemModifierMap']) && is_array($modResult['itemModifierMap'])) { + $modMap = $modResult['itemModifierMap']; + for ($mi = 0; $mi < count($toastItems); $mi++) { + if (isset($modMap[$toastItems[$mi]['name']])) { + $toastItems[$mi]['modifiers'] = $modMap[$toastItems[$mi]['name']]; + } + } + $response['steps'][] = "Mapped modifiers to " . count($modMap) . " items"; + } + if (!empty($modResult['stats'])) { + $response['steps'][] = "Modifier stats: " . json_encode($modResult['stats']); + } + } else { + $response['steps'][] = "Playwright modifier script returned empty output"; + } + } else { + $response['steps'][] = "Could not determine Toast URL for modifier extraction"; + } + } catch (Exception $e) { + $response['steps'][] = "Modifier extraction failed: " . $e->getMessage() . " - continuing without modifiers"; + } + } + + // Return directly if we have items + if (!empty($toastItems)) { + $response['OK'] = true; + $response['DATA'] = [ + 'business' => $toastBusiness, + 'categories' => $toastCategories, + 'items' => $toastItems, + 'modifiers' => $toastModifiers, + 'imageUrls' => [], + 'imageMappings' => $imageMappings, + 'headerCandidateIndices' => [], + ]; + $response['sourceUrl'] = !empty($targetUrl) ? $targetUrl : 'uploaded'; + $response['pagesProcessed'] = 1; + $response['imagesFound'] = count($imageDataArray); + $response['playwrightImagesCount'] = count($playwrightImages); + $response['parsedVia'] = 'toast_oo_state'; + jsonResponse($response); + } + } + } catch (Exception $e) { + $toastError = "Toast __OO_STATE__ parsing failed: " . $e->getMessage(); + $response['steps'][] = "$toastError - falling back to Claude"; + $response['DEBUG_TOAST_ERROR'] = $toastError; + } + } + + // ============================================================ + // Look for embedded JSON data (__NEXT_DATA__, window state, etc.) + // ============================================================ + $embeddedJsonData = ''; + foreach ($menuPages as $menuPage) { + if (preg_match_all('#]*id=["\']__NEXT_DATA__["\'][^>]*>([^<]+)#i', $menuPage['html'], $ndm)) { + foreach ($ndm[1] as $sc) $embeddedJsonData .= "\n--- __NEXT_DATA__ ---\n$sc"; + } + if (preg_match_all('#window\.__[A-Z_]+__\s*=\s*(\{[^;]+\});#', $menuPage['html'], $stm)) { + foreach ($stm[0] as $sm) $embeddedJsonData .= "\n--- WINDOW_STATE ---\n$sm"; + } + if (preg_match_all('#data-(?:props|page|state)=["\'](\{[^"\']+\})["\']#i', $menuPage['html'], $dpm)) { + foreach ($dpm[0] as $dp) $embeddedJsonData .= "\n--- DATA_PROPS ---\n$dp"; + } + if (preg_match_all('#]*type=["\']application/ld\+json["\'][^>]*>([^<]+)#i', $menuPage['html'], $ldm)) { + foreach ($ldm[1] as $sc) { + if (stripos($sc, 'menu') !== false || stripos($sc, 'MenuItem') !== false) { + $embeddedJsonData .= "\n--- JSON_LD_MENU ---\n$sc"; + } + } + } + } + + if (strlen($embeddedJsonData)) { + $response['DEBUG_EMBEDDED_JSON_FOUND'] = true; + $response['DEBUG_EMBEDDED_JSON_LENGTH'] = strlen($embeddedJsonData); + } else { + $response['DEBUG_EMBEDDED_JSON_FOUND'] = false; + } + + // Combine HTML, strip scripts/styles + $combinedHtml = ''; + foreach ($menuPages as $menuPage) { + $cleanHtml = $menuPage['html']; + $cleanHtml = preg_replace('#]*>.*?#is', '', $cleanHtml); + $cleanHtml = preg_replace('#]*>.*?#is', '', $cleanHtml); + $cleanHtml = preg_replace('##s', '', $cleanHtml); + $combinedHtml .= "\n--- PAGE: " . $menuPage['url'] . " ---\n" . $cleanHtml; + } + + if (strlen($embeddedJsonData)) { + $combinedHtml .= "\n\n=== EMBEDDED JSON DATA (may contain full menu) ===\n" . $embeddedJsonData; + } + + if (strlen($combinedHtml) > 100000) { + $combinedHtml = substr($combinedHtml, 0, 100000); + } + + // Server-side heading hierarchy detection + $headingHierarchy = []; + $hierarchyDesc = ''; + $scanPos = 0; + $currentH2 = ''; + while ($scanPos < strlen($combinedHtml)) { + $nextH2 = preg_match('#]*>#i', $combinedHtml, $m2, PREG_OFFSET_CAPTURE, $scanPos) ? $m2[0][1] : false; + $nextH3 = preg_match('#]*>#i', $combinedHtml, $m3, PREG_OFFSET_CAPTURE, $scanPos) ? $m3[0][1] : false; + + if ($nextH2 === false && $nextH3 === false) break; + + if ($nextH2 !== false && ($nextH3 === false || $nextH2 < $nextH3)) { + $closePos = stripos($combinedHtml, '', $nextH2); + if ($closePos === false) break; + $tagContent = substr($combinedHtml, $nextH2, $closePos + 5 - $nextH2); + $h2Raw = trim(strip_tags($tagContent)); + $h2Clean = trim(preg_replace('/[^a-zA-Z0-9 ]/', '', $h2Raw)); + if (strlen($h2Clean) && strtoupper($h2Clean) !== 'MENU' && stripos($h2Clean, 'copyright') === false) { + $currentH2 = $h2Raw; + } else { + $currentH2 = ''; + } + $scanPos = $closePos + 5; + } else { + $closePos = stripos($combinedHtml, '', $nextH3); + if ($closePos === false) break; + $tagContent = substr($combinedHtml, $nextH3, $closePos + 5 - $nextH3); + $h3Text = trim(strip_tags($tagContent)); + if (strlen($currentH2) && strlen($h3Text)) { + if (!isset($headingHierarchy[$currentH2])) $headingHierarchy[$currentH2] = []; + $headingHierarchy[$currentH2][] = $h3Text; + } + $scanPos = $closePos + 5; + } + } + + if (!empty($headingHierarchy)) { + foreach ($headingHierarchy as $hParent => $hChildren) { + $hierarchyDesc .= "- \"$hParent\" contains subsections: " . implode(', ', $hChildren) . "\n"; + } + $response['steps'][] = "Detected " . count($headingHierarchy) . " parent categories with subcategories from h2/h3 structure"; + } + + // ============================================================ + // Claude API call for generic pages + // ============================================================ + $systemPrompt = 'You are an expert at extracting structured menu data from restaurant website HTML. Extract ALL menu data visible in the HTML. Return valid JSON with these keys: business (object with name, address, phone, hours, brandColor), categories (array), modifiers (array), items (array with name, description, price, category, modifiers array, and imageUrl). CATEGORIES vs ITEMS (CRITICAL): A CATEGORY is a broad section heading that groups multiple items (e.g., \'Appetizers\', \'Tacos\', \'Drinks\', \'Desserts\'). An ITEM is an individual food or drink product with a name, description, and price. Do NOT create a category for each individual item. A typical restaurant has 5-15 categories and 30-150 items. If you find yourself creating more categories than items, you are wrong - those are items, not categories. Each item must have a \'category\' field set to the category it belongs to. CATEGORIES FORMAT: Each entry in the categories array can be either a simple string (for flat categories) OR an object with \'name\' and optional \'subcategories\' array. Example: ["Appetizers", {"name": "Drinks", "subcategories": ["Hot Drinks", "Cold Drinks"]}, "Desserts"]. SUBCATEGORY DETECTION: If a section header contains nested titled sections beneath it (sub-headers with their own items), the outer section is the PARENT and inner sections are SUBCATEGORIES. For items in subcategories, set their \'category\' field to the SUBCATEGORY name (not the parent). CRITICAL FOR IMAGES: Each menu item in the HTML is typically in a container (div, li, article) that also contains an img tag. Extract the img src URL and include it as \'imageUrl\' for that item. Look for img tags that are siblings or children within the same menu-item container. The image URL should be the full or relative src value from the img tag - NOT the alt text. CRITICAL: Extract EVERY menu item from ALL sources including embedded JSON (__NEXT_DATA__, window state, JSON-LD). For brandColor: suggest a vibrant hex (6 digits, no hash). For prices: numbers (e.g., 12.99). CRITICAL: Return ONLY valid JSON. All special characters in strings must be properly escaped. Never use smart/curly quotes. Use only ASCII double quotes for JSON string delimiters and backslash-escape any literal double quotes inside values.'; + + // Build message content + $messagesContent = []; + + // Add images (up to 10) + $imgLimit = min(count($imageDataArray), 10); + for ($i = 0; $i < $imgLimit; $i++) { + $messagesContent[] = ['type' => 'image', 'source' => $imageDataArray[$i]['source']]; + } + + // Add HTML text + $userText = "Extract menu data from this restaurant website HTML. The images above are from the same website - identify which ones are food photos that could be used as item images, and which could be header/banner images."; + if (strlen($hierarchyDesc)) { + $userText .= "\n\nIMPORTANT - DETECTED SECTION HIERARCHY FROM HTML HEADINGS:\n" + . "The following h2 sections contain h3 sub-sections. Use these as parent-subcategory relationships in your categories output:\n" + . $hierarchyDesc + . "For each parent above, include it in the categories array as an OBJECT with 'name' and 'subcategories' array. Items belonging to a subsection should have their 'category' field set to the SUBCATEGORY name (not the parent)."; + } + $userText .= "\n\nHere is the HTML content:\n\n" . $combinedHtml; + $messagesContent[] = ['type' => 'text', 'text' => $userText]; + + $requestBody = [ + 'model' => 'claude-sonnet-4-20250514', + 'max_tokens' => 16384, + 'temperature' => 0, + 'system' => $systemPrompt, + 'messages' => [['role' => 'user', 'content' => $messagesContent]], + ]; + + $response['steps'][] = "Sending to Claude API..."; + + $claudeResult = $httpPost( + 'https://api.anthropic.com/v1/messages', + json_encode($requestBody), + ['Content-Type: application/json', "x-api-key: $CLAUDE_API_KEY", 'anthropic-version: 2023-06-01'], + 120 + ); + + if ($claudeResult['code'] !== 200) { + $errorDetail = ''; + $errData = json_decode($claudeResult['body'], true); + if (!empty($errData['error']['message'])) { + $errorDetail = $errData['error']['message']; + } else { + $errorDetail = substr($claudeResult['body'], 0, 500); + } + throw new Exception("Claude API error: {$claudeResult['code']} - $errorDetail"); + } + + $claudeResponse = json_decode($claudeResult['body'], true); + if (empty($claudeResponse['content'])) throw new Exception("Empty response from Claude"); + + $responseText = ''; + foreach ($claudeResponse['content'] as $block) { + if (($block['type'] ?? '') === 'text') { + $responseText = $block['text']; + break; + } + } + + $responseText = $cleanClaudeJson($responseText); + $response['DEBUG_RAW_CLAUDE'] = $responseText; + + $menuData = json_decode($responseText, true); + if (!is_array($menuData)) { + $response['OK'] = false; + $response['MESSAGE'] = 'JSON parse error'; + $response['DEBUG_RAW_RESPONSE'] = substr($responseText, 0, 3000); + jsonResponse($response); + } + + // Build image URL list + $imageUrlList = []; + foreach ($imageDataArray as $imgData) { + if (!empty($imgData['url'])) $imageUrlList[] = $imgData['url']; + } + + // Ensure expected structure + if (!isset($menuData['business'])) $menuData['business'] = []; + if (!isset($menuData['categories'])) $menuData['categories'] = []; + if (!isset($menuData['modifiers'])) $menuData['modifiers'] = []; + if (!isset($menuData['items'])) $menuData['items'] = []; + + // Convert categories to expected format + $formattedCategories = []; + foreach ($menuData['categories'] as $cat) { + if (is_string($cat)) { + $formattedCategories[] = ['name' => $cat, 'itemCount' => 0]; + } elseif (is_array($cat)) { + $parentName = $cat['name'] ?? ''; + if (strlen($parentName)) { + $formattedCategories[] = ['name' => $parentName, 'itemCount' => 0]; + if (!empty($cat['subcategories']) && is_array($cat['subcategories'])) { + foreach ($cat['subcategories'] as $subcat) { + $subcatName = is_string($subcat) ? $subcat : ($subcat['name'] ?? ''); + if (strlen($subcatName)) { + $formattedCategories[] = ['name' => $subcatName, 'parentCategoryName' => $parentName, 'itemCount' => 0]; + } + } + } + } + } + } + $menuData['categories'] = $formattedCategories; + + // Fix "every item is a category" pattern + $totalItems = count($menuData['items']); + $totalCats = count($formattedCategories); + if ($totalCats > 10 && $totalItems > 0 && $totalCats > $totalItems * 0.5) { + $zeroCats = []; + $singleCats = []; + foreach ($formattedCategories as $fc) { + $fcCount = 0; + foreach ($menuData['items'] as $fi) { + if ($fi['category'] === $fc['name']) $fcCount++; + } + if ($fcCount === 0) $zeroCats[] = $fc['name']; + elseif ($fcCount === 1) $singleCats[] = $fc['name']; + } + + if (count($singleCats) > $totalCats * 0.6 && !empty($zeroCats)) { + $response['steps'][] = "Detected 'every item is a category' pattern (" . count($singleCats) . " single-item cats, " . count($zeroCats) . " empty cats) - collapsing"; + + $currentParent = $zeroCats[0]; + foreach ($formattedCategories as $fc) { + if (in_array($fc['name'], $zeroCats)) { + $currentParent = $fc['name']; + } else { + for ($ii = 0; $ii < count($menuData['items']); $ii++) { + if ($menuData['items'][$ii]['category'] === $fc['name']) { + $menuData['items'][$ii]['category'] = $currentParent; + } + } + } + } + + $fixedCategories = []; + foreach ($zeroCats as $zc) { + $zcCount = 0; + foreach ($menuData['items'] as $fi) { + if ($fi['category'] === $zc) $zcCount++; + } + $fixedCategories[] = ['name' => $zc, 'itemCount' => $zcCount]; + } + $menuData['categories'] = $fixedCategories; + $formattedCategories = $fixedCategories; + $response['steps'][] = "Collapsed to " . count($fixedCategories) . " categories"; + } + } + + // Server-side hierarchy enforcement from HTML heading structure + if (!empty($headingHierarchy)) { + $h3ToParent = []; + foreach ($headingHierarchy as $hParentName => $hChildren) { + foreach ($hChildren as $hChild) { + $h3ToParent[strtolower(trim($hChild))] = $hParentName; + } + } + + $hierarchyApplied = 0; + for ($i = 0; $i < count($formattedCategories); $i++) { + if (empty($formattedCategories[$i]['parentCategoryName'])) { + $catLower = strtolower(trim($formattedCategories[$i]['name'])); + if (isset($h3ToParent[$catLower])) { + $rawParent = $h3ToParent[$catLower]; + $matchedParent = ''; + foreach ($formattedCategories as $pcat) { + $parentNorm = strtolower(preg_replace('/[^a-zA-Z0-9 ]/', '', $rawParent)); + $parentNorm = trim(preg_replace('/\s*menu\s*$/i', '', $parentNorm)); + $pcatNorm = trim(preg_replace('/\s*menu\s*$/i', '', strtolower($pcat['name']))); + if ($pcatNorm === $parentNorm || strtolower($pcat['name']) === strtolower($rawParent)) { + $matchedParent = $pcat['name']; + break; + } + } + if (strlen($matchedParent)) { + $formattedCategories[$i]['parentCategoryName'] = $matchedParent; + $hierarchyApplied++; + } + } + } + } + if ($hierarchyApplied > 0) { + $menuData['categories'] = $formattedCategories; + $response['steps'][] = "Server-side hierarchy: applied $hierarchyApplied parent-child relationships"; + } + } + + // Items with subcategory field from Claude + for ($i = 0; $i < count($menuData['items']); $i++) { + if (!empty($menuData['items'][$i]['subcategory'])) { + $menuData['items'][$i]['category'] = $menuData['items'][$i]['subcategory']; + } + } + + // Add item IDs + for ($i = 0; $i < count($menuData['items']); $i++) { + $menuData['items'][$i]['id'] = 'item_' . ($i + 1); + } + + // Process item images + $itemsWithImages = 0; + for ($i = 0; $i < count($menuData['items']); $i++) { + $item = $menuData['items'][$i]; + + if (!empty($item['images']) && is_array($item['images'])) { + $imgObj = $item['images']; + $itemsWithImages++; + $filenames = []; + foreach ($imgObj as $sizeKey => $imgUrl) { + if (is_scalar($imgUrl) && strlen(trim((string)$imgUrl))) { + $filenames[$sizeKey] = basename((string)$imgUrl); + } + } + $menuData['items'][$i]['imageFilenames'] = $filenames; + + $primarySrc = $imgObj['src'] ?? $imgObj['large'] ?? $imgObj['medium'] ?? $imgObj['small'] ?? null; + if ($primarySrc) { + $menuData['items'][$i]['imageSrc'] = $primarySrc; + $menuData['items'][$i]['imageFilename'] = basename($primarySrc); + } + } elseif (!empty($item['imageUrl'])) { + $menuData['items'][$i]['imageSrc'] = $item['imageUrl']; + $menuData['items'][$i]['imageFilename'] = basename($item['imageUrl']); + $itemsWithImages++; + } elseif (!empty($item['imageSrc'])) { + $menuData['items'][$i]['imageFilename'] = basename($item['imageSrc']); + $itemsWithImages++; + } + } + $response['steps'][] = "Found images for $itemsWithImages of " . count($menuData['items']) . " items"; + + $menuData['imageUrls'] = $imageUrlList; + $menuData['headerCandidateIndices'] = []; + $menuData['imageMappings'] = $imageMappings; + + $response['OK'] = true; + $response['DATA'] = $menuData; + $response['sourceUrl'] = !empty($targetUrl) ? $targetUrl : 'uploaded'; + $response['pagesProcessed'] = count($menuPages); + $response['imagesFound'] = count($imageDataArray); + $response['playwrightImagesCount'] = count($playwrightImages); + +} catch (Exception $e) { + $response['MESSAGE'] = $e->getMessage(); +} + +jsonResponse($response); diff --git a/api/setup/checkDuplicate.php b/api/setup/checkDuplicate.php new file mode 100644 index 0000000..18e9e45 --- /dev/null +++ b/api/setup/checkDuplicate.php @@ -0,0 +1,73 @@ + true, 'duplicates' => []]; + +try { + $data = readJsonBody(); + + $bizName = trim($data['name'] ?? ''); + $addressLine1 = trim($data['addressLine1'] ?? ''); + $city = trim($data['city'] ?? ''); + $state = trim($data['state'] ?? ''); + $zip = trim($data['zip'] ?? ''); + + // Clean up city - remove trailing punctuation + $city = preg_replace('/[,.\s]+$/', '', $city); + + $qDuplicates = queryTimed(" + SELECT DISTINCT + b.ID AS BusinessID, + b.Name, + a.Line1, + a.City, + s.Abbreviation AS AddressState, + a.ZIPCode + FROM Businesses b + LEFT JOIN Addresses a ON a.BusinessID = b.ID + LEFT JOIN tt_States s ON s.ID = a.StateID + WHERE + LOWER(b.Name) = LOWER(?) + OR ( + LOWER(a.Line1) = LOWER(?) + AND LOWER(a.City) = LOWER(?) + AND a.Line1 != '' + AND a.City != '' + ) + ORDER BY b.Name + ", [$bizName, $addressLine1, $city]); + + foreach ($qDuplicates as $row) { + $addressParts = []; + if (!empty($row['Line1'])) $addressParts[] = $row['Line1']; + if (!empty($row['City'])) $addressParts[] = $row['City']; + if (!empty($row['AddressState'])) $addressParts[] = $row['AddressState']; + if (!empty($row['ZIPCode'])) $addressParts[] = $row['ZIPCode']; + + $response['duplicates'][] = [ + 'BusinessID' => (int)$row['BusinessID'], + 'Name' => $row['Name'], + 'Address' => implode(', ', $addressParts), + ]; + } + +} catch (Exception $e) { + $response['OK'] = false; + $response['error'] = $e->getMessage(); +} + +jsonResponse($response); diff --git a/api/setup/downloadImages.php b/api/setup/downloadImages.php new file mode 100644 index 0000000..20a3b8c --- /dev/null +++ b/api/setup/downloadImages.php @@ -0,0 +1,136 @@ + false, 'downloaded' => []]; + +try { + $data = readJsonBody(); + + $businessID = (int)($data['businessID'] ?? 0); + if ($businessID === 0) { + throw new Exception('businessID is required'); + } + + $uploadsPath = '/var/www/biz.payfrit.com/uploads'; + if (isDev()) { + $uploadsPath = '/opt/lucee/tomcat/webapps/ROOT/uploads'; + } + + $logosPath = "$uploadsPath/logos"; + $headersPath = "$uploadsPath/headers"; + + if (!is_dir($logosPath)) mkdir($logosPath, 0755, true); + if (!is_dir($headersPath)) mkdir($headersPath, 0755, true); + + // Download logo + if (!empty($data['logoUrl'])) { + $logoUrl = $data['logoUrl']; + $ext = '.png'; + if (stripos($logoUrl, '.jpg') !== false || stripos($logoUrl, '.jpeg') !== false) $ext = '.jpg'; + elseif (stripos($logoUrl, '.gif') !== false) $ext = '.gif'; + elseif (stripos($logoUrl, '.webp') !== false) $ext = '.webp'; + + $logoFile = "$logosPath/$businessID$ext"; + + try { + $ch = curl_init($logoUrl); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_FOLLOWLOCATION => true, + ]); + $content = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode === 200 && $content !== false) { + file_put_contents($logoFile, $content); + $response['downloaded'][] = [ + 'type' => 'logo', + 'url' => $logoUrl, + 'savedTo' => "/uploads/logos/$businessID$ext", + 'size' => strlen($content), + ]; + } else { + $response['downloaded'][] = [ + 'type' => 'logo', + 'url' => $logoUrl, + 'error' => "HTTP $httpCode", + ]; + } + } catch (Exception $e) { + $response['downloaded'][] = [ + 'type' => 'logo', + 'url' => $logoUrl, + 'error' => $e->getMessage(), + ]; + } + } + + // Download header + if (!empty($data['headerUrl'])) { + $headerUrl = $data['headerUrl']; + $ext = '.jpg'; + if (stripos($headerUrl, '.png') !== false) $ext = '.png'; + elseif (stripos($headerUrl, '.gif') !== false) $ext = '.gif'; + elseif (stripos($headerUrl, '.webp') !== false) $ext = '.webp'; + + $headerFile = "$headersPath/$businessID$ext"; + + try { + $ch = curl_init($headerUrl); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_FOLLOWLOCATION => true, + ]); + $content = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode === 200 && $content !== false) { + file_put_contents($headerFile, $content); + $response['downloaded'][] = [ + 'type' => 'header', + 'url' => $headerUrl, + 'savedTo' => "/uploads/headers/$businessID$ext", + 'size' => strlen($content), + ]; + } else { + $response['downloaded'][] = [ + 'type' => 'header', + 'url' => $headerUrl, + 'error' => "HTTP $httpCode", + ]; + } + } catch (Exception $e) { + $response['downloaded'][] = [ + 'type' => 'header', + 'url' => $headerUrl, + 'error' => $e->getMessage(), + ]; + } + } + + $response['OK'] = true; + $response['businessID'] = $businessID; + +} catch (Exception $e) { + $response['error'] = $e->getMessage(); +} + +jsonResponse($response); diff --git a/api/setup/importBusiness.php b/api/setup/importBusiness.php new file mode 100644 index 0000000..8bb3c75 --- /dev/null +++ b/api/setup/importBusiness.php @@ -0,0 +1,225 @@ + false, 'steps' => [], 'errors' => [], 'warnings' => []]; + +try { + $data = readJsonBody(); + if (empty($data)) throw new Exception('No request body provided'); + + $dryRun = !empty($data['dryRun']); + if ($dryRun) $response['steps'][] = 'DRY RUN MODE - no changes will be made'; + + if (empty($data['business']['name'])) throw new Exception('business.name is required'); + + $biz = $data['business']; + $ownerUserID = (int)($data['ownerUserID'] ?? 1); + + // Step 1: Create or find business + $response['steps'][] = 'Step 1: Creating business record...'; + + $qCheck = queryTimed("SELECT ID FROM Businesses WHERE Name = ? LIMIT 1", [$biz['name']]); + + if (!empty($qCheck)) { + $businessID = (int)$qCheck[0]['ID']; + $response['steps'][] = "Business already exists with ID: $businessID"; + $response['warnings'][] = 'Existing business found - will add to existing menu'; + } elseif (!$dryRun) { + queryTimed( + "INSERT INTO Businesses (Name, UserID, AddressID, DeliveryZIPCodes, AddedOn) VALUES (?, ?, 0, '', NOW())", + [$biz['name'], $ownerUserID] + ); + $businessID = (int)lastInsertId(); + $response['steps'][] = "Created business with ID: $businessID"; + } else { + $businessID = 0; + $response['steps'][] = 'Would create business: ' . $biz['name']; + } + + // Step 2: Create modifier templates + $response['steps'][] = 'Step 2: Creating modifier templates...'; + + $templateMap = []; // Maps template string ID to database ItemID + $templates = $data['modifierTemplates'] ?? []; + + foreach ($templates as $tmpl) { + $templateStringID = $tmpl['id']; + $templateName = $tmpl['name']; + $required = !empty($tmpl['required']); + $maxSelections = (int)($tmpl['maxSelections'] ?? 0); + + if (!$dryRun) { + $qTmpl = queryOne( + "SELECT ID FROM Items WHERE BusinessID = ? AND Name = ? AND ParentItemID = 0 AND ID IN (SELECT TemplateItemID FROM lt_ItemID_TemplateItemID)", + [$businessID, $templateName] + ); + + if ($qTmpl) { + $templateItemID = (int)$qTmpl['ID']; + $response['steps'][] = "Template exists: $templateName (ID: $templateItemID)"; + } else { + queryTimed( + "INSERT INTO Items (BusinessID, Name, ParentItemID, Price, IsActive, RequiresChildSelection, MaxNumSelectionReq, SortOrder, IsCollapsible) VALUES (?, ?, 0, 0, 1, ?, ?, 0, 1)", + [$businessID, $templateName, $required ? 1 : 0, $maxSelections] + ); + $templateItemID = (int)lastInsertId(); + $response['steps'][] = "Created template: $templateName (ID: $templateItemID)"; + } + + $templateMap[$templateStringID] = $templateItemID; + + // Create template options + $options = $tmpl['options'] ?? []; + $optionOrder = 1; + foreach ($options as $opt) { + $optName = $opt['name']; + $optPrice = (float)($opt['price'] ?? 0); + $optDefault = !empty($opt['isDefault']); + + $qOpt = queryOne( + "SELECT ID FROM Items WHERE BusinessID = ? AND Name = ? AND ParentItemID = ?", + [$businessID, $optName, $templateItemID] + ); + + if (!$qOpt) { + queryTimed( + "INSERT INTO Items (BusinessID, Name, ParentItemID, Price, IsActive, IsCheckedByDefault, SortOrder) VALUES (?, ?, ?, ?, 1, ?, ?)", + [$businessID, $optName, $templateItemID, $optPrice, $optDefault ? 1 : 0, $optionOrder] + ); + } + $optionOrder++; + } + } else { + $response['steps'][] = "Would create template: $templateName with " . count($tmpl['options'] ?? []) . " options"; + $templateMap[$templateStringID] = 0; + } + } + + // Step 3: Create categories + $response['steps'][] = 'Step 3: Creating categories...'; + + $categoryMap = []; + $categories = $data['categories'] ?? []; + $catOrder = 1; + + foreach ($categories as $cat) { + $catName = $cat['name']; + + if (!$dryRun) { + $qCat = queryOne( + "SELECT ID FROM Items WHERE BusinessID = ? AND Name = ? AND ParentItemID = 0 AND ID NOT IN (SELECT TemplateItemID FROM lt_ItemID_TemplateItemID)", + [$businessID, $catName] + ); + + if ($qCat) { + $categoryItemID = (int)$qCat['ID']; + $response['steps'][] = "Category exists: $catName"; + } else { + queryTimed( + "INSERT INTO Items (BusinessID, Name, ParentItemID, Price, IsActive, SortOrder) VALUES (?, ?, 0, 0, 1, ?)", + [$businessID, $catName, $catOrder] + ); + $categoryItemID = (int)lastInsertId(); + $response['steps'][] = "Created category: $catName (ID: $categoryItemID)"; + } + + $categoryMap[$catName] = $categoryItemID; + } else { + $response['steps'][] = "Would create category: $catName"; + $categoryMap[$catName] = 0; + } + $catOrder++; + } + + // Step 4: Create menu items + $response['steps'][] = 'Step 4: Creating menu items...'; + + $totalItems = 0; + $totalLinks = 0; + + foreach ($categories as $cat) { + $catName = $cat['name']; + $categoryItemID = $categoryMap[$catName]; + $items = $cat['items'] ?? []; + $itemOrder = 1; + + foreach ($items as $item) { + $itemName = $item['name']; + $itemDesc = $item['description'] ?? ''; + $itemPrice = (float)($item['price'] ?? 0); + + if (!$dryRun) { + $qItem = queryOne( + "SELECT ID FROM Items WHERE BusinessID = ? AND Name = ? AND ParentItemID = ?", + [$businessID, $itemName, $categoryItemID] + ); + + if ($qItem) { + $menuItemID = (int)$qItem['ID']; + } else { + queryTimed( + "INSERT INTO Items (BusinessID, Name, Description, ParentItemID, Price, IsActive, SortOrder) VALUES (?, ?, ?, ?, ?, 1, ?)", + [$businessID, $itemName, $itemDesc, $categoryItemID, $itemPrice, $itemOrder] + ); + $menuItemID = (int)lastInsertId(); + } + + // Link modifier templates + $modifiers = $item['modifiers'] ?? []; + $modOrder = 1; + foreach ($modifiers as $modRef) { + if (isset($templateMap[$modRef])) { + $templateItemID = $templateMap[$modRef]; + $qLink = queryOne( + "SELECT 1 FROM lt_ItemID_TemplateItemID WHERE ItemID = ? AND TemplateItemID = ?", + [$menuItemID, $templateItemID] + ); + if (!$qLink) { + queryTimed( + "INSERT INTO lt_ItemID_TemplateItemID (ItemID, TemplateItemID, SortOrder) VALUES (?, ?, ?)", + [$menuItemID, $templateItemID, $modOrder] + ); + $totalLinks++; + } + $modOrder++; + } else { + $response['warnings'][] = "Unknown modifier reference: $modRef on item: $itemName"; + } + } + } + + $totalItems++; + $itemOrder++; + } + } + + $response['steps'][] = "Created $totalItems menu items with $totalLinks template links"; + + $response['OK'] = true; + $response['summary'] = [ + 'businessID' => $businessID, + 'businessName' => $biz['name'], + 'categoriesCreated' => count($categories), + 'templatesCreated' => count($templates), + 'itemsCreated' => $totalItems, + 'templateLinksCreated' => $totalLinks, + 'dryRun' => $dryRun, + ]; + + $response['steps'][] = $dryRun ? 'DRY RUN COMPLETE - no changes were made' : 'IMPORT COMPLETE!'; + +} catch (Exception $e) { + $response['errors'][] = $e->getMessage(); +} + +jsonResponse($response); diff --git a/api/setup/lookupTaxRate.php b/api/setup/lookupTaxRate.php new file mode 100644 index 0000000..4b085a4 --- /dev/null +++ b/api/setup/lookupTaxRate.php @@ -0,0 +1,133 @@ + false, 'taxRate' => 0, 'message' => '', 'city' => '', 'state' => '']; + +try { + // Load Claude API key + $CLAUDE_API_KEY = ''; + $configPath = __DIR__ . '/../../config/claude.json'; + if (file_exists($configPath)) { + $configData = json_decode(file_get_contents($configPath), true); + $CLAUDE_API_KEY = $configData['apiKey'] ?? ''; + } + + // Get ZIP code + $zipCode = trim($_GET['zip'] ?? $_POST['zip'] ?? ''); + + // Validate ZIP format + if (!preg_match('/^\d{5}(-\d{4})?$/', $zipCode)) { + $response['message'] = 'Invalid ZIP code format'; + jsonResponse($response); + } + + $zipCode = substr($zipCode, 0, 5); + + // Zippopotam.us lookup + $ch = curl_init("https://api.zippopotam.us/us/$zipCode"); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + ]); + $zipResult = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode !== 200) { + $response['message'] = 'ZIP lookup failed'; + jsonResponse($response); + } + + $zipData = json_decode($zipResult, true); + if (empty($zipData['places'][0])) { + $response['message'] = 'No location data for ZIP'; + jsonResponse($response); + } + + $place = $zipData['places'][0]; + $cityName = $place['place name'] ?? ''; + $stateAbbr = strtoupper($place['state abbreviation'] ?? ''); + $response['city'] = $cityName; + $response['state'] = $stateAbbr; + + // State base rates fallback + $stateTaxRates = [ + 'AL' => 4, 'AK' => 0, 'AZ' => 5.6, 'AR' => 6.5, 'CA' => 7.25, + 'CO' => 2.9, 'CT' => 6.35, 'DE' => 0, 'FL' => 6, 'GA' => 4, + 'HI' => 4, 'ID' => 6, 'IL' => 6.25, 'IN' => 7, 'IA' => 6, + 'KS' => 6.5, 'KY' => 6, 'LA' => 4.45, 'ME' => 5.5, 'MD' => 6, + 'MA' => 6.25, 'MI' => 6, 'MN' => 6.875, 'MS' => 7, 'MO' => 4.225, + 'MT' => 0, 'NE' => 5.5, 'NV' => 6.85, 'NH' => 0, 'NJ' => 6.625, + 'NM' => 4.875, 'NY' => 4, 'NC' => 4.75, 'ND' => 5, 'OH' => 5.75, + 'OK' => 4.5, 'OR' => 0, 'PA' => 6, 'RI' => 7, 'SC' => 6, + 'SD' => 4.2, 'TN' => 7, 'TX' => 6.25, 'UT' => 6.1, 'VT' => 6, + 'VA' => 5.3, 'WA' => 6.5, 'WV' => 6, 'WI' => 5, 'WY' => 4, 'DC' => 6, + ]; + + if (empty($CLAUDE_API_KEY)) { + if (isset($stateTaxRates[$stateAbbr])) { + $response['taxRate'] = $stateTaxRates[$stateAbbr]; + $response['OK'] = true; + $response['message'] = 'State base rate only (no API key)'; + } + jsonResponse($response); + } + + // Ask Claude for exact tax rate + $requestBody = json_encode([ + 'model' => 'claude-sonnet-4-20250514', + 'max_tokens' => 100, + 'temperature' => 0, + 'messages' => [[ + 'role' => 'user', + 'content' => "What is the current combined sales tax rate (state + county + city + special districts) for $cityName, $stateAbbr (ZIP code $zipCode)? Return ONLY a single number representing the percentage, like 10.25 for 10.25%. No text, no explanation, just the number.", + ]], + ]); + + $ch = curl_init('https://api.anthropic.com/v1/messages'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $requestBody, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + "x-api-key: $CLAUDE_API_KEY", + 'anthropic-version: 2023-06-01', + ], + ]); + $claudeResult = curl_exec($ch); + $claudeHttpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($claudeHttpCode === 200) { + $claudeResponse = json_decode($claudeResult, true); + if (!empty($claudeResponse['content'][0]['text'])) { + $rateText = trim($claudeResponse['content'][0]['text']); + $rateText = preg_replace('/[^0-9.]/', '', $rateText); + $rateVal = (float)$rateText; + if ($rateVal > 0 && $rateVal < 20) { + $response['taxRate'] = $rateVal; + $response['OK'] = true; + $response['message'] = "Tax rate for $cityName, $stateAbbr"; + } else { + $response['message'] = 'Could not parse tax rate'; + } + } + } else { + $response['message'] = 'Claude API error'; + } + +} catch (Exception $e) { + $response['message'] = 'Error: ' . $e->getMessage(); +} + +jsonResponse($response); diff --git a/api/setup/reimportBigDeans.php b/api/setup/reimportBigDeans.php new file mode 100644 index 0000000..0b11a22 --- /dev/null +++ b/api/setup/reimportBigDeans.php @@ -0,0 +1,291 @@ + 'CLEAR', 'message' => $dryRun ? 'Would clear existing data' : 'Cleared existing data']; + +// STEP 2: Modifier Templates + +$tplLettuce = insertItem($bizId, 'Lettuce', '', 0, 0, 1, 0, 0, 1, 1, 1, $dryRun); +insertItem($bizId, 'Regular', '', $tplLettuce, 0, 1, 1, 0, 0, 0, 1, $dryRun); +insertItem($bizId, 'Extra', '', $tplLettuce, 0, 1, 0, 0, 0, 0, 2, $dryRun); +insertItem($bizId, 'Light', '', $tplLettuce, 0, 1, 0, 0, 0, 0, 3, $dryRun); +insertItem($bizId, 'None', '', $tplLettuce, 0, 1, 0, 0, 0, 0, 4, $dryRun); +$actions[] = ['step' => 'TEMPLATE', 'name' => 'Lettuce', 'id' => $tplLettuce]; + +$tplTomato = insertItem($bizId, 'Tomato', '', 0, 0, 1, 0, 0, 1, 1, 1, $dryRun); +insertItem($bizId, 'Regular', '', $tplTomato, 0, 1, 1, 0, 0, 0, 1, $dryRun); +insertItem($bizId, 'Extra', '', $tplTomato, 0, 1, 0, 0, 0, 0, 2, $dryRun); +insertItem($bizId, 'Light', '', $tplTomato, 0, 1, 0, 0, 0, 0, 3, $dryRun); +insertItem($bizId, 'None', '', $tplTomato, 0, 1, 0, 0, 0, 0, 4, $dryRun); +$actions[] = ['step' => 'TEMPLATE', 'name' => 'Tomato', 'id' => $tplTomato]; + +$tplSauce = insertItem($bizId, "Big Dean's Sauce", '', 0, 0, 1, 0, 0, 1, 1, 1, $dryRun); +insertItem($bizId, 'Regular', '', $tplSauce, 0, 1, 1, 0, 0, 0, 1, $dryRun); +insertItem($bizId, 'Extra', '', $tplSauce, 0, 1, 0, 0, 0, 0, 2, $dryRun); +insertItem($bizId, 'Light', '', $tplSauce, 0, 1, 0, 0, 0, 0, 3, $dryRun); +insertItem($bizId, 'None', '', $tplSauce, 0, 1, 0, 0, 0, 0, 4, $dryRun); +$actions[] = ['step' => 'TEMPLATE', 'name' => "Big Dean's Sauce", 'id' => $tplSauce]; + +$tplOnions = insertItem($bizId, 'Onions', '', 0, 0, 1, 0, 0, 1, 1, 1, $dryRun); +insertItem($bizId, 'None', '', $tplOnions, 0, 1, 1, 0, 0, 0, 1, $dryRun); +insertItem($bizId, 'Grilled', '', $tplOnions, 0, 1, 0, 0, 0, 0, 2, $dryRun); +insertItem($bizId, 'Raw', '', $tplOnions, 0, 1, 0, 0, 0, 0, 3, $dryRun); +$actions[] = ['step' => 'TEMPLATE', 'name' => 'Onions', 'id' => $tplOnions]; + +$tplPickle = insertItem($bizId, 'Pickle Spear', '', 0, 0, 1, 0, 0, 1, 1, 1, $dryRun); +insertItem($bizId, 'None', '', $tplPickle, 0, 1, 1, 0, 0, 0, 1, $dryRun); +insertItem($bizId, 'Add Pickle', '', $tplPickle, 0, 1, 0, 0, 0, 0, 2, $dryRun); +$actions[] = ['step' => 'TEMPLATE', 'name' => 'Pickle Spear', 'id' => $tplPickle]; + +$tplWingStyle = insertItem($bizId, 'Wing Style', '', 0, 0, 1, 0, 1, 1, 1, 1, $dryRun); +insertItem($bizId, 'Bone-In', '', $tplWingStyle, 0, 1, 1, 0, 0, 0, 1, $dryRun); +insertItem($bizId, 'Boneless', '', $tplWingStyle, 0, 1, 0, 0, 0, 0, 2, $dryRun); +$actions[] = ['step' => 'TEMPLATE', 'name' => 'Wing Style', 'id' => $tplWingStyle]; + +$tplSize = insertItem($bizId, 'Size', '', 0, 0, 1, 0, 1, 1, 1, 1, $dryRun); +insertItem($bizId, 'Regular', '', $tplSize, 0, 1, 1, 0, 0, 0, 1, $dryRun); +insertItem($bizId, 'Small', '', $tplSize, 0, 1, 0, 0, 0, 0, 2, $dryRun); +$actions[] = ['step' => 'TEMPLATE', 'name' => 'Size', 'id' => $tplSize]; + +$tplPortion = insertItem($bizId, 'Portion', '', 0, 0, 1, 0, 1, 1, 1, 1, $dryRun); +insertItem($bizId, 'Full Order', '', $tplPortion, 0, 1, 1, 0, 0, 0, 1, $dryRun); +insertItem($bizId, 'Half Order', '', $tplPortion, 0, 1, 0, 0, 0, 0, 2, $dryRun); +$actions[] = ['step' => 'TEMPLATE', 'name' => 'Portion', 'id' => $tplPortion]; + +$tplSideExtras = insertItem($bizId, 'Add Extras', '$.50 each', 0, 0, 1, 0, 0, 0, 0, 1, $dryRun); +insertItem($bizId, 'Sour Cream', '', $tplSideExtras, 0.50, 1, 0, 0, 0, 0, 1, $dryRun); +insertItem($bizId, 'Jalapenos', '', $tplSideExtras, 0.50, 1, 0, 0, 0, 0, 2, $dryRun); +insertItem($bizId, 'BBQ Sauce', '', $tplSideExtras, 0.50, 1, 0, 0, 0, 0, 3, $dryRun); +insertItem($bizId, 'Ranch', '', $tplSideExtras, 0.50, 1, 0, 0, 0, 0, 4, $dryRun); +insertItem($bizId, 'Blue Cheese', '', $tplSideExtras, 0.50, 1, 0, 0, 0, 0, 5, $dryRun); +insertItem($bizId, 'Mayo', '', $tplSideExtras, 0.50, 1, 0, 0, 0, 0, 6, $dryRun); +insertItem($bizId, 'Aioli', '', $tplSideExtras, 0.50, 1, 0, 0, 0, 0, 7, $dryRun); +$actions[] = ['step' => 'TEMPLATE', 'name' => 'Add Extras', 'id' => $tplSideExtras]; + +$tplGuac = insertItem($bizId, 'Guacamole', '', 0, 0, 1, 0, 0, 1, 1, 1, $dryRun); +insertItem($bizId, 'Without Guacamole', '', $tplGuac, 0, 1, 1, 0, 0, 0, 1, $dryRun); +insertItem($bizId, 'With Guacamole', '', $tplGuac, 0, 1, 0, 0, 0, 0, 2, $dryRun); +$actions[] = ['step' => 'TEMPLATE', 'name' => 'Guacamole', 'id' => $tplGuac]; + +$tplAddIns = insertItem($bizId, 'Add-ins', '', 0, 0, 1, 0, 0, 0, 0, 1, $dryRun); +insertItem($bizId, 'Chicken', '', $tplAddIns, 0, 1, 0, 0, 0, 0, 1, $dryRun); +insertItem($bizId, 'Steak', '', $tplAddIns, 0, 1, 0, 0, 0, 0, 2, $dryRun); +insertItem($bizId, 'Shrimp', '', $tplAddIns, 0, 1, 0, 0, 0, 0, 3, $dryRun); +$actions[] = ['step' => 'TEMPLATE', 'name' => 'Add-ins', 'id' => $tplAddIns]; + +$tplChiliExtras = insertItem($bizId, 'Extras', '', 0, 0, 1, 0, 0, 0, 0, 1, $dryRun); +insertItem($bizId, 'Add Cheese', '', $tplChiliExtras, 0.50, 1, 0, 0, 0, 0, 1, $dryRun); +insertItem($bizId, 'Add Onions', '', $tplChiliExtras, 0.50, 1, 0, 0, 0, 0, 2, $dryRun); +$actions[] = ['step' => 'TEMPLATE', 'name' => 'Extras (Chili)', 'id' => $tplChiliExtras]; + +$tplDressing = insertItem($bizId, 'Dressing', '', 0, 0, 1, 0, 1, 1, 1, 1, $dryRun); +insertItem($bizId, 'Italian', '', $tplDressing, 0, 1, 0, 0, 0, 0, 1, $dryRun); +insertItem($bizId, 'Ranch', '', $tplDressing, 0, 1, 1, 0, 0, 0, 2, $dryRun); +insertItem($bizId, 'Balsamic', '', $tplDressing, 0, 1, 0, 0, 0, 0, 3, $dryRun); +insertItem($bizId, 'Caesar', '', $tplDressing, 0, 1, 0, 0, 0, 0, 4, $dryRun); +insertItem($bizId, 'Blue Cheese', '', $tplDressing, 0, 1, 0, 0, 0, 0, 5, $dryRun); +insertItem($bizId, 'Thousand Island', '', $tplDressing, 0, 1, 0, 0, 0, 0, 6, $dryRun); +insertItem($bizId, 'French', '', $tplDressing, 0, 1, 0, 0, 0, 0, 7, $dryRun); +$actions[] = ['step' => 'TEMPLATE', 'name' => 'Dressing', 'id' => $tplDressing]; + +$tplSaladProtein = insertItem($bizId, 'Add Protein', '', 0, 0, 1, 0, 0, 1, 1, 1, $dryRun); +insertItem($bizId, 'No Protein', '', $tplSaladProtein, 0, 1, 1, 0, 0, 0, 1, $dryRun); +insertItem($bizId, 'Chicken Breast or Burger Patty', '', $tplSaladProtein, 0, 1, 0, 0, 0, 0, 2, $dryRun); +insertItem($bizId, 'Grilled Shrimp', '', $tplSaladProtein, 0, 1, 0, 0, 0, 0, 3, $dryRun); +insertItem($bizId, 'Grilled Mahi Mahi', '', $tplSaladProtein, 0, 1, 0, 0, 0, 0, 4, $dryRun); +$actions[] = ['step' => 'TEMPLATE', 'name' => 'Add Protein (Salad)', 'id' => $tplSaladProtein]; + +$tplHotDogExtras = insertItem($bizId, 'Extras', '', 0, 0, 1, 0, 0, 0, 0, 1, $dryRun); +insertItem($bizId, 'Add Sauerkraut', '', $tplHotDogExtras, 0.50, 1, 0, 0, 0, 0, 1, $dryRun); +insertItem($bizId, 'Add Cheese', '', $tplHotDogExtras, 0.50, 1, 0, 0, 0, 0, 2, $dryRun); +$actions[] = ['step' => 'TEMPLATE', 'name' => 'Extras (Hot Dog)', 'id' => $tplHotDogExtras]; + +// CATEGORIES AND ITEMS + +// World Famous Burgers +$catBurgers = insertItem($bizId, 'World Famous Burgers', '', 0, 0, 1, 0, 0, 0, 0, 1, $dryRun); +$actions[] = ['step' => 'CATEGORY', 'name' => 'World Famous Burgers', 'id' => $catBurgers]; + +$burger1 = insertItem($bizId, "Big Dean's Cheeseburger", 'Double meat double cheese - The burger that made Santa Monica famous!', $catBurgers, 0, 1, 0, 0, 0, 0, 1, $dryRun); +$burger2 = insertItem($bizId, 'Single Beef Burger with Cheese', '', $catBurgers, 0, 1, 0, 0, 0, 0, 2, $dryRun); +$burger3 = insertItem($bizId, 'Single Beef Burger', '', $catBurgers, 0, 1, 0, 0, 0, 0, 3, $dryRun); +$burger4 = insertItem($bizId, 'Beyond Burger', 'Vegan burger patty w/ lettuce, tomato, and pickle spear', $catBurgers, 0, 1, 0, 0, 0, 0, 4, $dryRun); +$burger5 = insertItem($bizId, 'Garden Burger', 'Vegetable patty prepared in the style of our burgers', $catBurgers, 0, 1, 0, 0, 0, 0, 5, $dryRun); +$burger6 = insertItem($bizId, 'Chili Size Burger', 'Beef burger served open faced topped with all beef chili, cheese, and onions', $catBurgers, 0, 1, 0, 0, 0, 0, 6, $dryRun); + +foreach ([$burger1, $burger2, $burger3, $burger4, $burger5, $burger6] as $bid) { + linkTemplate($bid, $tplLettuce, 1, $dryRun); + linkTemplate($bid, $tplTomato, 2, $dryRun); + linkTemplate($bid, $tplSauce, 3, $dryRun); + linkTemplate($bid, $tplOnions, 4, $dryRun); + linkTemplate($bid, $tplPickle, 5, $dryRun); +} +$actions[] = ['step' => 'ITEMS', 'category' => 'Burgers', 'count' => 6]; + +// Snacks and Sides +$catSides = insertItem($bizId, 'Snacks and Sides', '', 0, 0, 1, 0, 0, 0, 0, 2, $dryRun); +$actions[] = ['step' => 'CATEGORY', 'name' => 'Snacks and Sides', 'id' => $catSides]; + +$side1 = insertItem($bizId, 'Buffalo Wings', '', $catSides, 0, 1, 0, 0, 0, 0, 1, $dryRun); +linkTemplate($side1, $tplWingStyle, 1, $dryRun); +linkTemplate($side1, $tplSideExtras, 2, $dryRun); + +$side2 = insertItem($bizId, 'Frings', '1/2 French fries and 1/2 onion rings', $catSides, 0, 1, 0, 0, 0, 0, 2, $dryRun); +linkTemplate($side2, $tplSideExtras, 1, $dryRun); + +$side3 = insertItem($bizId, 'French Fries', '', $catSides, 0, 1, 0, 0, 0, 0, 3, $dryRun); +linkTemplate($side3, $tplSize, 1, $dryRun); +linkTemplate($side3, $tplSideExtras, 2, $dryRun); + +$side4 = insertItem($bizId, 'Onion Rings', '', $catSides, 0, 1, 0, 0, 0, 0, 4, $dryRun); +linkTemplate($side4, $tplSize, 1, $dryRun); +linkTemplate($side4, $tplSideExtras, 2, $dryRun); + +$side5 = insertItem($bizId, 'Chili Fries', '', $catSides, 0, 1, 0, 0, 0, 0, 5, $dryRun); +linkTemplate($side5, $tplChiliExtras, 1, $dryRun); +linkTemplate($side5, $tplSideExtras, 2, $dryRun); + +$side6 = insertItem($bizId, 'Chicken Fingers and Fries', '', $catSides, 0, 1, 0, 0, 0, 0, 6, $dryRun); +linkTemplate($side6, $tplPortion, 1, $dryRun); +linkTemplate($side6, $tplSideExtras, 2, $dryRun); + +$side7 = insertItem($bizId, 'Chips and Salsa', '', $catSides, 0, 1, 0, 0, 0, 0, 7, $dryRun); +linkTemplate($side7, $tplGuac, 1, $dryRun); + +$side8 = insertItem($bizId, 'Cheese Quesadilla', '', $catSides, 0, 1, 0, 0, 0, 0, 8, $dryRun); +linkTemplate($side8, $tplAddIns, 1, $dryRun); + +$side9 = insertItem($bizId, 'Cheese Nachos', '', $catSides, 0, 1, 0, 0, 0, 0, 9, $dryRun); +linkTemplate($side9, $tplAddIns, 1, $dryRun); + +insertItem($bizId, 'Chili Cheese Nachos', '', $catSides, 0, 1, 0, 0, 0, 0, 10, $dryRun); +$side11 = insertItem($bizId, 'Mozzarella Sticks', '', $catSides, 0, 1, 0, 0, 0, 0, 11, $dryRun); +linkTemplate($side11, $tplSideExtras, 1, $dryRun); +$actions[] = ['step' => 'ITEMS', 'category' => 'Snacks and Sides', 'count' => 11]; + +// Sandwiches +$catSandwiches = insertItem($bizId, 'Sandwiches', '', 0, 0, 1, 0, 0, 0, 0, 3, $dryRun); +$actions[] = ['step' => 'CATEGORY', 'name' => 'Sandwiches', 'id' => $catSandwiches]; + +insertItem($bizId, 'Cajun Mahi Mahi', 'Lettuce, tomato, aioli, and guacamole', $catSandwiches, 0, 1, 0, 0, 0, 0, 1, $dryRun); +insertItem($bizId, 'Philly Cheese Steak', 'Grilled peppers and onions', $catSandwiches, 0, 1, 0, 0, 0, 0, 2, $dryRun); +insertItem($bizId, 'Cajun Chicken', 'Lettuce, tomato, and spicy aioli', $catSandwiches, 0, 1, 0, 0, 0, 0, 3, $dryRun); +insertItem($bizId, 'Turkey', 'Lettuce, tomato, and mayo', $catSandwiches, 0, 1, 0, 0, 0, 0, 4, $dryRun); +insertItem($bizId, 'Turkey Club', 'Bacon, lettuce, tomato, and mayo', $catSandwiches, 0, 1, 0, 0, 0, 0, 5, $dryRun); +insertItem($bizId, 'Grilled Cheese', 'Sourdough and American cheese', $catSandwiches, 0, 1, 0, 0, 0, 0, 6, $dryRun); +insertItem($bizId, 'Grilled Ham and Cheese', '', $catSandwiches, 0, 1, 0, 0, 0, 0, 7, $dryRun); +insertItem($bizId, 'Grilled Chicken', 'Lettuce, tomato, and mayo', $catSandwiches, 0, 1, 0, 0, 0, 0, 8, $dryRun); +insertItem($bizId, 'BBQ Chicken', 'Lettuce, tomato, and BBQ sauce', $catSandwiches, 0, 1, 0, 0, 0, 0, 9, $dryRun); +insertItem($bizId, 'Fried Chicken', 'Lettuce, tomato, and mayo', $catSandwiches, 0, 1, 0, 0, 0, 0, 10, $dryRun); +insertItem($bizId, 'Chicken Caesar Wrap', '', $catSandwiches, 0, 1, 0, 0, 0, 0, 11, $dryRun); +$actions[] = ['step' => 'ITEMS', 'category' => 'Sandwiches', 'count' => 11]; + +// Soups & Salads +$catSoups = insertItem($bizId, 'Soups & Salads', '', 0, 0, 1, 0, 0, 0, 0, 4, $dryRun); +$actions[] = ['step' => 'CATEGORY', 'name' => 'Soups & Salads', 'id' => $catSoups]; + +insertItem($bizId, 'New England Clam Chowder', '', $catSoups, 0, 1, 0, 0, 0, 0, 1, $dryRun); +insertItem($bizId, 'Chili Bowl', 'With Cheese and Onion', $catSoups, 0, 1, 0, 0, 0, 0, 2, $dryRun); + +$salad1 = insertItem($bizId, 'Beach Salad', 'Spring mix greens, tomatoes, cucumbers, feta cheese, and carrots served in a crisp flour tortilla shell', $catSoups, 0, 1, 0, 0, 0, 0, 3, $dryRun); +linkTemplate($salad1, $tplDressing, 1, $dryRun); +linkTemplate($salad1, $tplSaladProtein, 2, $dryRun); + +$salad2 = insertItem($bizId, 'Caesar Salad', 'Romaine lettuce, croutons, and Caesar dressing', $catSoups, 0, 1, 0, 0, 0, 0, 4, $dryRun); +linkTemplate($salad2, $tplSaladProtein, 1, $dryRun); +$actions[] = ['step' => 'ITEMS', 'category' => 'Soups & Salads', 'count' => 4]; + +// Tacos +$catTacos = insertItem($bizId, 'Tacos', 'Served two per order on flour tortillas unless otherwise noted', 0, 0, 1, 0, 0, 0, 0, 5, $dryRun); +$actions[] = ['step' => 'CATEGORY', 'name' => 'Tacos', 'id' => $catTacos]; + +insertItem($bizId, 'Grilled Chicken Tacos', 'Taco sauce, cheese, lettuce, tomatoes, and ranch', $catTacos, 0, 1, 0, 0, 0, 0, 1, $dryRun); +insertItem($bizId, 'Fried Fish Tacos', 'Taco sauce, cheese, lettuce, tomatoes, and ranch', $catTacos, 0, 1, 0, 0, 0, 0, 2, $dryRun); +insertItem($bizId, 'Mahi Mahi Tacos', 'Cheese, cabbage, and tomatoes on corn tortillas with a side of guacamole and salsa', $catTacos, 0, 1, 0, 0, 0, 0, 3, $dryRun); +insertItem($bizId, 'Steak Fajita Tacos', 'Grilled with peppers, onions, and a side of cheese, tomatoes, and lettuce', $catTacos, 0, 1, 0, 0, 0, 0, 4, $dryRun); +insertItem($bizId, 'Shrimp Fajita Tacos', 'Grilled with peppers, onions, and a side of cheese, tomatoes, and lettuce', $catTacos, 0, 1, 0, 0, 0, 0, 5, $dryRun); +$actions[] = ['step' => 'ITEMS', 'category' => 'Tacos', 'count' => 5]; + +// Seafood +$catSeafood = insertItem($bizId, 'Seafood', '', 0, 0, 1, 0, 0, 0, 0, 6, $dryRun); +$actions[] = ['step' => 'CATEGORY', 'name' => 'Seafood', 'id' => $catSeafood]; + +insertItem($bizId, 'Fried Fantail Shrimp', 'With Fries', $catSeafood, 0, 1, 0, 0, 0, 0, 1, $dryRun); +insertItem($bizId, 'Clam Boat with Fries', '', $catSeafood, 0, 1, 0, 0, 0, 0, 2, $dryRun); +$seafood3 = insertItem($bizId, 'Fish and Chips', '', $catSeafood, 0, 1, 0, 0, 0, 0, 3, $dryRun); +linkTemplate($seafood3, $tplPortion, 1, $dryRun); +insertItem($bizId, 'Grilled Shrimp & Baby Scallop Skewers', '', $catSeafood, 0, 1, 0, 0, 0, 0, 4, $dryRun); +$actions[] = ['step' => 'ITEMS', 'category' => 'Seafood', 'count' => 4]; + +// Hot Dogs & Such +$catHotDogs = insertItem($bizId, 'Hot Dogs & Such', '', 0, 0, 1, 0, 0, 0, 0, 7, $dryRun); +$actions[] = ['step' => 'CATEGORY', 'name' => 'Hot Dogs & Such', 'id' => $catHotDogs]; + +$hotdog1 = insertItem($bizId, 'Hot Dog', 'Topped with onions, tomatoes, and relish', $catHotDogs, 0, 1, 0, 0, 0, 0, 1, $dryRun); +linkTemplate($hotdog1, $tplHotDogExtras, 1, $dryRun); + +insertItem($bizId, 'Corn Dog', '', $catHotDogs, 0, 1, 0, 0, 0, 0, 2, $dryRun); + +$hotdog3 = insertItem($bizId, 'Chili Dog', 'Topped with all beef chili', $catHotDogs, 0, 1, 0, 0, 0, 0, 3, $dryRun); +linkTemplate($hotdog3, $tplHotDogExtras, 1, $dryRun); +$actions[] = ['step' => 'ITEMS', 'category' => 'Hot Dogs & Such', 'count' => 3]; + +// Summary +if (!$dryRun) { + $qCount = queryOne("SELECT COUNT(*) AS cnt FROM Items WHERE BusinessID = ?", [$bizId]); + $totalItems = (int)$qCount['cnt']; + + $qTemplateCount = queryOne("SELECT COUNT(*) AS cnt FROM Items WHERE BusinessID = ? AND ParentItemID = 0 AND IsCollapsible = 1", [$bizId]); + $templateCount = (int)$qTemplateCount['cnt']; + + $qLinkCount = queryOne("SELECT COUNT(*) AS cnt FROM lt_ItemID_TemplateItemID tl JOIN Items i ON i.ID = tl.ItemID WHERE i.BusinessID = ?", [$bizId]); + $linkCount = (int)$qLinkCount['cnt']; +} else { + $totalItems = 'N/A (dry run)'; + $templateCount = 'N/A (dry run)'; + $linkCount = 'N/A (dry run)'; +} + +jsonResponse([ + 'OK' => true, + 'DryRun' => $dryRun, + 'BusinessID' => $bizId, + 'TotalItems' => $totalItems, + 'TemplateCount' => $templateCount, + 'TemplateLinkCount' => $linkCount, + 'Actions' => $actions, +]); diff --git a/api/setup/saveWizard.php b/api/setup/saveWizard.php new file mode 100644 index 0000000..fae495f --- /dev/null +++ b/api/setup/saveWizard.php @@ -0,0 +1,647 @@ + false, 'steps' => [], 'errors' => []]; + +$uploadsPath = isDev() + ? '/opt/lucee/tomcat/webapps/ROOT/uploads' + : '/var/www/biz.payfrit.com/uploads'; +$itemsDir = "$uploadsPath/items"; + +/** + * Resize image maintaining aspect ratio, fitting within maxSize box. + */ +function resizeToFit($img, int $maxSize) { + $w = imagesx($img); + $h = imagesy($img); + if ($w <= $maxSize && $h <= $maxSize) return $img; + + if ($w > $h) { + $newW = $maxSize; + $newH = (int)($h * ($maxSize / $w)); + } else { + $newH = $maxSize; + $newW = (int)($w * ($maxSize / $h)); + } + $dst = imagecreatetruecolor($newW, $newH); + imagecopyresampled($dst, $img, 0, 0, 0, 0, $newW, $newH, $w, $h); + imagedestroy($img); + return $dst; +} + +/** + * Create square thumbnail (center crop). + */ +function createSquareThumb($img, int $size) { + $w = imagesx($img); + $h = imagesy($img); + + // Resize so smallest dimension equals size + if ($w > $h) { + $newH = $size; + $newW = (int)($w * ($size / $h)); + } else { + $newW = $size; + $newH = (int)($h * ($size / $w)); + } + $resized = imagecreatetruecolor($newW, $newH); + imagecopyresampled($resized, $img, 0, 0, 0, 0, $newW, $newH, $w, $h); + + // Center crop to square + $w2 = imagesx($resized); + $h2 = imagesy($resized); + $x = ($w2 > $h2) ? (int)(($w2 - $h2) / 2) : 0; + $y = ($h2 > $w2) ? (int)(($h2 - $w2) / 2) : 0; + $cropSize = min($w2, $h2); + + $thumb = imagecreatetruecolor($size, $size); + imagecopyresampled($thumb, $resized, 0, 0, $x, $y, $size, $size, $cropSize, $cropSize); + imagedestroy($resized); + return $thumb; +} + +/** + * Download and save item image from URL in three sizes. + */ +function downloadItemImage(int $itemID, string $imageUrl, string $itemsDir): bool { + try { + $imageUrl = trim($imageUrl); + if (empty($imageUrl)) return false; + + if (str_starts_with($imageUrl, '//')) { + $imageUrl = 'https:' . $imageUrl; + } elseif ($imageUrl[0] === '/') { + return false; // Can't resolve relative URL + } + + // Upsize DoorDash CDN thumbnails + if (stripos($imageUrl, 'cdn4dd.com/p/') !== false && stripos($imageUrl, 'width=150') !== false) { + $imageUrl = str_ireplace('width=150', 'width=600', $imageUrl); + $imageUrl = str_ireplace('height=150', 'height=600', $imageUrl); + } + + $ch = curl_init($imageUrl); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_HTTPHEADER => [ + 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + ], + ]); + $content = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE); + curl_close($ch); + + if ($httpCode !== 200 || $content === false) return false; + if (stripos($contentType, 'image') === false) return false; + + // Write to temp file + $tempFile = "$itemsDir/temp_{$itemID}_" . uniqid() . '.tmp'; + file_put_contents($tempFile, $content); + + $img = imagecreatefromstring($content); + if (!$img) { + @unlink($tempFile); + return false; + } + + // Thumbnail (128x128 square) + $thumbImg = imagecreatefromstring($content); + $thumb = createSquareThumb($thumbImg, 128); + imagejpeg($thumb, "$itemsDir/{$itemID}_thumb.jpg", 85); + imagedestroy($thumb); + + // Medium (400px max) + $medImg = imagecreatefromstring($content); + $medium = resizeToFit($medImg, 400); + imagejpeg($medium, "$itemsDir/{$itemID}_medium.jpg", 85); + imagedestroy($medium); + + // Full (1200px max) + $full = resizeToFit($img, 1200); + imagejpeg($full, "$itemsDir/{$itemID}.jpg", 90); + imagedestroy($full); + + @unlink($tempFile); + return true; + } catch (Exception $e) { + if (isset($tempFile) && file_exists($tempFile)) @unlink($tempFile); + return false; + } +} + +try { + $raw = file_get_contents('php://input'); + if (empty($raw)) throw new Exception('No request body provided'); + + // Clean control characters + $raw = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $raw); + + $data = json_decode($raw, true); + if ($data === null) { + $response['errors'][] = 'JSON parse failed: ' . json_last_error_msg(); + jsonResponse($response); + } + + $businessId = (int)($data['businessId'] ?? 0); + $userId = (int)($data['userId'] ?? 0); + $providedMenuId = (int)($data['menuId'] ?? 0); + $wizardData = $data['data'] ?? []; + $biz = $wizardData['business'] ?? []; + + // If no businessId, create a new business + if ($businessId === 0) { + $response['steps'][] = 'No businessId provided - creating new business'; + + $bizName = is_string($biz['name'] ?? null) ? $biz['name'] : ''; + if (empty($bizName)) throw new Exception('Business name is required to create new business'); + if ($userId === 0) throw new Exception('userId is required to create new business'); + + $bizPhone = is_string($biz['phone'] ?? null) ? trim($biz['phone']) : ''; + $bizTaxRate = is_numeric($biz['taxRatePercent'] ?? null) ? (float)$biz['taxRatePercent'] / 100 : 0; + + // Brand colors (6-digit hex without #) + $bizBrandColor = is_string($biz['brandColor'] ?? null) ? trim($biz['brandColor']) : ''; + $bizBrandColor = ltrim($bizBrandColor, '#'); + if (!preg_match('/^[0-9A-Fa-f]{6}$/', $bizBrandColor)) $bizBrandColor = ''; + + $bizBrandColorLight = is_string($biz['brandColorLight'] ?? null) ? trim($biz['brandColorLight']) : ''; + $bizBrandColorLight = ltrim($bizBrandColorLight, '#'); + if (!preg_match('/^[0-9A-Fa-f]{6}$/', $bizBrandColorLight)) $bizBrandColorLight = ''; + + // Address fields + $addressLine1 = is_string($biz['addressLine1'] ?? null) ? trim($biz['addressLine1']) : ''; + $city = is_string($biz['city'] ?? null) ? trim($biz['city']) : ''; + $state = is_string($biz['state'] ?? null) ? trim($biz['state']) : ''; + $zip = is_string($biz['zip'] ?? null) ? trim($biz['zip']) : ''; + + $city = preg_replace('/[,.\s]+$/', '', $city); + + // Look up state ID + $stateID = 0; + $response['steps'][] = "State value received: '$state' (len: " . strlen($state) . ')'; + if (strlen($state)) { + $qState = queryOne("SELECT ID FROM tt_States WHERE Abbreviation = ?", [strtoupper($state)]); + $response['steps'][] = "State lookup for '" . strtoupper($state) . "' found " . ($qState ? '1' : '0') . ' records'; + if ($qState) { + $stateID = (int)$qState['ID']; + $response['steps'][] = "Using stateID: $stateID"; + } + } + + // Lat/lng from Toast + $bizLat = is_numeric($biz['latitude'] ?? null) ? (float)$biz['latitude'] : 0; + $bizLng = is_numeric($biz['longitude'] ?? null) ? (float)$biz['longitude'] : 0; + + // Create address + $addrParams = [ + strlen($addressLine1) ? $addressLine1 : 'Address pending', + strlen($city) ? $city : '', + $stateID > 0 ? $stateID : null, + strlen($zip) ? $zip : '', + $userId, + 2, // AddressTypeID + $bizLat != 0 ? $bizLat : null, + $bizLng != 0 ? $bizLng : null, + ]; + queryTimed( + "INSERT INTO Addresses (Line1, City, StateID, ZIPCode, UserID, AddressTypeID, Latitude, Longitude, AddedOn) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())", + $addrParams + ); + $addressId = (int)lastInsertId(); + $response['steps'][] = "Created address record (ID: $addressId)"; + + // Community meal type + $communityMealType = (int)($wizardData['communityMealType'] ?? 1); + if ($communityMealType < 1 || $communityMealType > 2) $communityMealType = 1; + + // Create business + queryTimed( + "INSERT INTO Businesses (Name, Phone, UserID, AddressID, DeliveryZIPCodes, CommunityMealType, TaxRate, BrandColor, BrandColorLight, AddedOn) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())", + [ + $bizName, $bizPhone, $userId, $addressId, + strlen($zip) ? $zip : '', + $communityMealType, $bizTaxRate, + strlen($bizBrandColor) ? $bizBrandColor : null, + strlen($bizBrandColorLight) ? $bizBrandColorLight : null, + ] + ); + $businessId = (int)lastInsertId(); + $response['steps'][] = "Created new business: $bizName (ID: $businessId)"; + + // Link address to business + queryTimed("UPDATE Addresses SET BusinessID = ? WHERE ID = ?", [$businessId, $addressId]); + $response['steps'][] = 'Linked address to business'; + + // Default task types + $defaultTaskTypes = [ + ['name' => 'Call Staff', 'icon' => 'notifications', 'color' => '#9C27B0', 'description' => 'Request staff assistance'], + ['name' => 'Chat With Staff', 'icon' => 'chat', 'color' => '#2196F3', 'description' => 'Open a chat conversation'], + ['name' => 'Pay With Cash', 'icon' => 'payments', 'color' => '#4CAF50', 'description' => 'Request to pay with cash'], + ['name' => 'Deliver to Table', 'icon' => 'restaurant', 'color' => '#FF9800', 'description' => 'Deliver completed order to table'], + ['name' => 'Order Ready for Pickup', 'icon' => 'shopping_bag', 'color' => '#00BCD4', 'description' => 'Notify customer their order is ready'], + ['name' => 'Deliver to Address', 'icon' => 'local_shipping', 'color' => '#795548', 'description' => 'Deliver order to customer address'], + ]; + + foreach ($defaultTaskTypes as $sortOrder => $tt) { + queryTimed( + "INSERT INTO tt_TaskTypes (Name, Description, Icon, Color, BusinessID, SortOrder) VALUES (?, ?, ?, ?, ?, ?)", + [$tt['name'], $tt['description'], $tt['icon'], $tt['color'], $businessId, $sortOrder + 1] + ); + } + $response['steps'][] = 'Created 6 default task types'; + + // Default task categories + $defaultTaskCategories = [ + ['name' => 'Service Point', 'color' => '#F44336'], + ['name' => 'Kitchen', 'color' => '#FF9800'], + ['name' => 'Bar', 'color' => '#9C27B0'], + ['name' => 'Cleaning', 'color' => '#4CAF50'], + ['name' => 'Management', 'color' => '#2196F3'], + ['name' => 'Delivery', 'color' => '#00BCD4'], + ['name' => 'General', 'color' => '#607D8B'], + ]; + + foreach ($defaultTaskCategories as $tc) { + queryTimed( + "INSERT INTO TaskCategories (BusinessID, Name, Color) VALUES (?, ?, ?)", + [$businessId, $tc['name'], $tc['color']] + ); + } + $response['steps'][] = 'Created 7 default task categories'; + + // Default kitchen station + queryTimed( + "INSERT INTO Stations (BusinessID, Name, Color, SortOrder) VALUES (?, 'Kitchen', '#FF9800', 1)", + [$businessId] + ); + $response['steps'][] = 'Created default Kitchen station'; + + // Save business hours + if (!empty($biz['hoursSchedule']) && is_array($biz['hoursSchedule'])) { + $hoursSchedule = $biz['hoursSchedule']; + $response['steps'][] = 'Processing ' . count($hoursSchedule) . ' days of hours'; + + foreach ($hoursSchedule as $dayData) { + if (!is_array($dayData)) continue; + $dayID = (int)($dayData['dayId'] ?? 0); + $openTime = is_string($dayData['open'] ?? null) ? $dayData['open'] : '09:00'; + $closeTime = is_string($dayData['close'] ?? null) ? $dayData['close'] : '17:00'; + + if (strlen($openTime) === 5) $openTime .= ':00'; + if (strlen($closeTime) === 5) $closeTime .= ':00'; + + queryTimed( + "INSERT INTO Hours (BusinessID, DayID, OpenTime, ClosingTime) VALUES (?, ?, ?, ?)", + [$businessId, $dayID, $openTime, $closeTime] + ); + } + $response['steps'][] = 'Created ' . count($hoursSchedule) . ' hours records'; + } + } else { + // Verify existing business + $qBiz = queryOne("SELECT ID, Name AS BusinessName FROM Businesses WHERE ID = ?", [$businessId]); + if (!$qBiz) throw new Exception("Business not found: $businessId"); + $response['steps'][] = 'Found existing business: ' . $qBiz['BusinessName']; + } + + // Build modifier template map + $modTemplates = $wizardData['modifiers'] ?? []; + $templateMap = []; + + $response['steps'][] = 'Processing ' . count($modTemplates) . ' modifier templates...'; + + foreach ($modTemplates as $i => $tmpl) { + $tmplName = is_string($tmpl['name'] ?? null) ? $tmpl['name'] : ''; + if (empty($tmplName)) { + $response['steps'][] = "Warning: Skipping modifier template with no name at index $i"; + continue; + } + $required = !empty($tmpl['required']); + $options = is_array($tmpl['options'] ?? null) ? $tmpl['options'] : []; + $tmplType = is_string($tmpl['type'] ?? null) ? $tmpl['type'] : 'select'; + $maxSel = ($tmplType === 'checkbox') ? 0 : 1; + + $response['steps'][] = "Template '$tmplName' has " . count($options) . ' options (type: ' . (is_array($options) ? 'array' : 'other') . ')'; + + // Check if template exists + $qTmpl = queryOne( + "SELECT ID FROM Items WHERE BusinessID = ? AND Name = ? AND ParentItemID = 0 AND CategoryID = 0", + [$businessId, $tmplName] + ); + + if ($qTmpl) { + $templateItemID = (int)$qTmpl['ID']; + $response['steps'][] = "Template exists: $tmplName (ID: $templateItemID)"; + } else { + queryTimed( + "INSERT INTO Items (BusinessID, Name, ParentItemID, CategoryID, Price, IsActive, RequiresChildSelection, MaxNumSelectionReq, SortOrder) VALUES (?, ?, 0, 0, 0, 1, ?, ?, 0)", + [$businessId, $tmplName, $required ? 1 : 0, $maxSel] + ); + $templateItemID = (int)lastInsertId(); + $response['steps'][] = "Created template: $tmplName (ID: $templateItemID)"; + } + + $templateMap[$tmplName] = $templateItemID; + + // Create/update template options + $optionOrder = 1; + foreach ($options as $opt) { + if (!is_array($opt)) continue; + if (!is_string($opt['name'] ?? null) || empty($opt['name'])) continue; + + $optName = $opt['name']; + $optPrice = is_numeric($opt['price'] ?? null) ? (float)$opt['price'] : 0; + $optSelected = !empty($opt['selected']) ? 1 : 0; + + $qOpt = queryOne( + "SELECT ID FROM Items WHERE BusinessID = ? AND Name = ? AND ParentItemID = ?", + [$businessId, $optName, $templateItemID] + ); + + if (!$qOpt) { + queryTimed( + "INSERT INTO Items (BusinessID, Name, ParentItemID, CategoryID, Price, IsActive, IsCheckedByDefault, SortOrder) VALUES (?, ?, ?, 0, ?, 1, ?, ?)", + [$businessId, $optName, $templateItemID, $optPrice, $optSelected, $optionOrder] + ); + } + $optionOrder++; + } + } + + // Create or find menu + if ($providedMenuId > 0) { + $menuID = $providedMenuId; + $qName = queryOne("SELECT Name FROM Menus WHERE ID = ?", [$menuID]); + $menuName = $qName ? $qName['Name'] : "Menu $menuID"; + $response['steps'][] = "Using provided menu: $menuName (ID: $menuID)"; + } else { + $menuName = is_string($wizardData['menuName'] ?? null) && strlen(trim($wizardData['menuName'] ?? '')) + ? trim($wizardData['menuName']) + : 'Main Menu'; + + $menuStartTime = is_string($wizardData['menuStartTime'] ?? null) ? trim($wizardData['menuStartTime']) : ''; + $menuEndTime = is_string($wizardData['menuEndTime'] ?? null) ? trim($wizardData['menuEndTime']) : ''; + + if (strlen($menuStartTime) === 5) $menuStartTime .= ':00'; + if (strlen($menuEndTime) === 5) $menuEndTime .= ':00'; + + // Validate menu hours against business hours + if (strlen($menuStartTime) && strlen($menuEndTime)) { + $qHours = queryOne( + "SELECT MIN(OpenTime) AS earliestOpen, MAX(ClosingTime) AS latestClose FROM Hours WHERE BusinessID = ?", + [$businessId] + ); + if ($qHours && $qHours['earliestOpen'] !== null && $qHours['latestClose'] !== null) { + $earliestOpen = substr($qHours['earliestOpen'], 0, 8); + $latestClose = substr($qHours['latestClose'], 0, 8); + if ($menuStartTime < $earliestOpen || $menuEndTime > $latestClose) { + throw new Exception("Menu hours ($menuStartTime - $menuEndTime) must be within business operating hours ($earliestOpen - $latestClose)"); + } + $response['steps'][] = "Validated menu hours against business hours ($earliestOpen - $latestClose)"; + } + } + + $qMenu = queryOne( + "SELECT ID FROM Menus WHERE BusinessID = ? AND Name = ? AND IsActive = 1", + [$businessId, $menuName] + ); + + if ($qMenu) { + $menuID = (int)$qMenu['ID']; + if (strlen($menuStartTime) && strlen($menuEndTime)) { + queryTimed("UPDATE Menus SET StartTime = ?, EndTime = ? WHERE ID = ?", [$menuStartTime, $menuEndTime, $menuID]); + $response['steps'][] = "Updated existing menu: $menuName (ID: $menuID) with hours $menuStartTime - $menuEndTime"; + } else { + $response['steps'][] = "Using existing menu: $menuName (ID: $menuID)"; + } + } else { + queryTimed( + "INSERT INTO Menus (BusinessID, Name, DaysActive, StartTime, EndTime, SortOrder, IsActive, AddedOn) VALUES (?, ?, 127, ?, ?, 0, 1, NOW())", + [$businessId, $menuName, strlen($menuStartTime) ? $menuStartTime : null, strlen($menuEndTime) ? $menuEndTime : null] + ); + $menuID = (int)lastInsertId(); + $timeInfo = (strlen($menuStartTime) && strlen($menuEndTime)) ? " ($menuStartTime - $menuEndTime)" : ' (all day)'; + $response['steps'][] = "Created menu: $menuName$timeInfo (ID: $menuID)"; + } + } + + // Build category map + $categories = $wizardData['categories'] ?? []; + $categoryMap = []; + + $response['steps'][] = 'Processing ' . count($categories) . ' categories...'; + + // First pass: top-level categories + $catOrder = 1; + foreach ($categories as $c => $cat) { + $catName = is_string($cat['name'] ?? null) ? $cat['name'] : ''; + if (empty($catName)) { + $response['steps'][] = "Warning: Skipping category with no name at index $c"; + continue; + } + // Skip subcategories in first pass + if (!empty($cat['parentCategoryName'])) continue; + + $qCat = queryOne( + "SELECT ID FROM Categories WHERE BusinessID = ? AND Name = ? AND MenuID = ?", + [$businessId, $catName, $menuID] + ); + + if ($qCat) { + $categoryID = (int)$qCat['ID']; + $response['steps'][] = "Category exists: $catName (ID: $categoryID)"; + } else { + queryTimed( + "INSERT INTO Categories (BusinessID, MenuID, Name, SortOrder) VALUES (?, ?, ?, ?)", + [$businessId, $menuID, $catName, $catOrder] + ); + $categoryID = (int)lastInsertId(); + $response['steps'][] = "Created category: $catName in menu $menuName (ID: $categoryID)"; + } + + $categoryMap[$catName] = $categoryID; + $catOrder++; + } + + // Second pass: subcategories + foreach ($categories as $cat) { + $catName = is_string($cat['name'] ?? null) ? $cat['name'] : ''; + if (empty($catName)) continue; + $parentName = trim($cat['parentCategoryName'] ?? ''); + if (empty($parentName)) continue; + + $parentCatID = $categoryMap[$parentName] ?? 0; + if ($parentCatID === 0) { + $response['steps'][] = "Warning: Parent category '$parentName' not found for subcategory '$catName'"; + continue; + } + + $qCat = queryOne( + "SELECT ID FROM Categories WHERE BusinessID = ? AND Name = ? AND MenuID = ? AND ParentCategoryID = ?", + [$businessId, $catName, $menuID, $parentCatID] + ); + + if ($qCat) { + $categoryID = (int)$qCat['ID']; + $response['steps'][] = "Subcategory exists: $catName under $parentName (ID: $categoryID)"; + } else { + queryTimed( + "INSERT INTO Categories (BusinessID, MenuID, Name, ParentCategoryID, SortOrder) VALUES (?, ?, ?, ?, ?)", + [$businessId, $menuID, $catName, $parentCatID, $catOrder] + ); + $categoryID = (int)lastInsertId(); + $response['steps'][] = "Created subcategory: $catName under $parentName (ID: $categoryID)"; + } + + $categoryMap[$catName] = $categoryID; + $catOrder++; + } + + // Create menu items + $items = $wizardData['items'] ?? []; + $response['steps'][] = 'Processing ' . count($items) . ' menu items...'; + + $totalItems = 0; + $totalLinks = 0; + $totalImages = 0; + $categoryItemOrder = []; + $itemIdMap = []; + + foreach ($items as $n => $item) { + if (!is_array($item)) continue; + + $itemName = is_string($item['name'] ?? null) ? $item['name'] : ''; + if (empty($itemName)) { + $response['steps'][] = "Warning: Skipping item with no name at index $n"; + continue; + } + + $itemDesc = is_string($item['description'] ?? null) ? $item['description'] : ''; + $itemPrice = is_numeric($item['price'] ?? null) ? (float)$item['price'] : 0; + $itemCategory = is_string($item['category'] ?? null) ? $item['category'] : ''; + $itemModifiers = is_array($item['modifiers'] ?? null) ? $item['modifiers'] : []; + + if (empty($itemCategory) || !isset($categoryMap[$itemCategory])) { + $response['steps'][] = "Warning: Item '$itemName' has unknown category - skipping"; + continue; + } + + $categoryID = $categoryMap[$itemCategory]; + + // Track sort order within category + if (!isset($categoryItemOrder[$itemCategory])) $categoryItemOrder[$itemCategory] = 1; + $itemOrder = $categoryItemOrder[$itemCategory]++; + + // Check if item exists + $qItem = queryOne( + "SELECT ID FROM Items WHERE BusinessID = ? AND Name = ? AND CategoryID = ?", + [$businessId, $itemName, $categoryID] + ); + + if ($qItem) { + $menuItemID = (int)$qItem['ID']; + queryTimed( + "UPDATE Items SET Description = ?, Price = ?, SortOrder = ? WHERE ID = ?", + [$itemDesc, $itemPrice, $itemOrder, $menuItemID] + ); + } else { + queryTimed( + "INSERT INTO Items (BusinessID, Name, Description, ParentItemID, CategoryID, Price, IsActive, SortOrder) VALUES (?, ?, ?, 0, ?, ?, 1, ?)", + [$businessId, $itemName, $itemDesc, $categoryID, $itemPrice, $itemOrder] + ); + $menuItemID = (int)lastInsertId(); + } + + $totalItems++; + + // Track mapping for image uploads + $frontendId = is_string($item['id'] ?? null) ? $item['id'] : ''; + if (strlen($frontendId)) $itemIdMap[$frontendId] = $menuItemID; + $itemIdMap[$itemName] = $menuItemID; + + // Link modifier templates + $modOrder = 1; + foreach ($itemModifiers as $modRef) { + if (is_string($modRef)) { + $modName = $modRef; + } elseif (is_array($modRef) && isset($modRef['name'])) { + $modName = $modRef['name']; + } else { + continue; + } + if (isset($templateMap[$modName])) { + $templateItemID = $templateMap[$modName]; + $qLink = queryOne( + "SELECT 1 FROM lt_ItemID_TemplateItemID WHERE ItemID = ? AND TemplateItemID = ?", + [$menuItemID, $templateItemID] + ); + if (!$qLink) { + queryTimed( + "INSERT INTO lt_ItemID_TemplateItemID (ItemID, TemplateItemID, SortOrder) VALUES (?, ?, ?)", + [$menuItemID, $templateItemID, $modOrder] + ); + $totalLinks++; + } + $modOrder++; + } + } + + // Download item image if URL provided + $itemImageUrl = is_string($item['imageUrl'] ?? null) ? trim($item['imageUrl']) : ''; + if (strlen($itemImageUrl)) { + if (!is_dir($itemsDir)) mkdir($itemsDir, 0755, true); + if (downloadItemImage($menuItemID, $itemImageUrl, $itemsDir)) { + $totalImages++; + } + } + } + + $imageNote = $totalImages > 0 ? " and $totalImages images downloaded" : ''; + $response['steps'][] = "Created/updated $totalItems items with $totalLinks modifier links$imageNote"; + + $response['OK'] = true; + $response['summary'] = [ + 'businessId' => $businessId, + 'categoriesProcessed' => count($categories), + 'templatesProcessed' => count($modTemplates), + 'itemsProcessed' => $totalItems, + 'linksCreated' => $totalLinks, + 'imagesDownloaded' => $totalImages, + 'itemIdMap' => $itemIdMap, + ]; + + // Clean up temp folder from ZIP upload + $tempFolder = is_string($data['tempFolder'] ?? null) ? trim($data['tempFolder']) : ''; + if (strlen($tempFolder) && preg_match('/^[a-f0-9]{32}$/', $tempFolder)) { + $webroot = isDev() + ? '/opt/lucee/tomcat/webapps/ROOT' + : '/var/www/biz.payfrit.com'; + $tempFolderPath = "$webroot/temp/menu-import/$tempFolder"; + if (is_dir($tempFolderPath)) { + exec("rm -rf " . escapeshellarg($tempFolderPath)); + $response['steps'][] = "Cleaned up temp folder: $tempFolder"; + } + } + +} catch (Exception $e) { + $response['errors'][] = $e->getMessage(); +} + +jsonResponse($response); diff --git a/api/setup/testUpload.php b/api/setup/testUpload.php new file mode 100644 index 0000000..ee7bbf8 --- /dev/null +++ b/api/setup/testUpload.php @@ -0,0 +1,86 @@ + false]; + +try { + // Step 1: Check form fields + $response['step'] = 'checking form fields'; + $response['formFields'] = array_keys($_FILES); + + // Step 2: Find file fields + $response['step'] = 'finding file fields'; + $uploadedFiles = []; + foreach ($_FILES as $fieldName => $fileInfo) { + if (preg_match('/^file[0-9]+$/', $fieldName)) { + $uploadedFiles[] = $fieldName; + } + } + $response['uploadedFiles'] = $uploadedFiles; + + if (empty($uploadedFiles)) { + throw new Exception('No files found'); + } + + // Step 3: Create temp directory + $response['step'] = 'creating temp dir'; + $uploadDir = sys_get_temp_dir() . '/test_uploads'; + if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true); + $response['uploadDir'] = $uploadDir; + + // Step 4: Upload first file + $response['step'] = 'uploading file'; + $fieldName = $uploadedFiles[0]; + $file = $_FILES[$fieldName]; + + $allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf']; + if (!in_array($file['type'], $allowedTypes)) { + throw new Exception('File type not accepted: ' . $file['type']); + } + + $destPath = $uploadDir . '/' . basename($file['name']); + move_uploaded_file($file['tmp_name'], $destPath); + + $response['uploadResult'] = true; + $response['serverFile'] = basename($file['name']); + $response['serverFileExt'] = pathinfo($file['name'], PATHINFO_EXTENSION); + + // Step 5: Read the file + $response['step'] = 'reading file'; + $response['filePath'] = $destPath; + $response['fileExists'] = file_exists($destPath); + + // Step 6: Try image operations + $fileExt = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); + if (in_array($fileExt, ['jpg', 'jpeg', 'png', 'gif', 'webp'])) { + $response['step'] = 'reading image'; + $imageInfo = getimagesize($destPath); + if ($imageInfo) { + $response['imageWidth'] = $imageInfo[0]; + $response['imageHeight'] = $imageInfo[1]; + } + + $response['step'] = 'converting to base64'; + $fileContent = file_get_contents($destPath); + $base64Content = base64_encode($fileContent); + $response['base64Length'] = strlen($base64Content); + } + + // Step 7: Cleanup + $response['step'] = 'cleanup'; + unlink($destPath); + + $response['OK'] = true; + $response['step'] = 'complete'; + +} catch (Exception $e) { + $response['MESSAGE'] = $e->getMessage(); +} + +jsonResponse($response); diff --git a/api/setup/uploadSavedPage.php b/api/setup/uploadSavedPage.php new file mode 100644 index 0000000..6657b14 --- /dev/null +++ b/api/setup/uploadSavedPage.php @@ -0,0 +1,178 @@ + false, 'MESSAGE' => '', 'URL' => '']; + +try { + $webroot = isDev() + ? '/opt/lucee/tomcat/webapps/ROOT' + : '/var/www/biz.payfrit.com'; + $tempBaseDir = "$webroot/temp/menu-import"; + + // Create temp directory if needed + if (!is_dir($tempBaseDir)) { + mkdir($tempBaseDir, 0755, true); + } + + // Cleanup: delete folders older than 1 hour + try { + $dirs = glob("$tempBaseDir/*", GLOB_ONLYDIR); + $oneHourAgo = time() - 3600; + foreach ($dirs as $dir) { + if (filemtime($dir) < $oneHourAgo) { + // Recursively delete + $it = new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS); + $files = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST); + foreach ($files as $file) { + $file->isDir() ? rmdir($file->getRealPath()) : unlink($file->getRealPath()); + } + rmdir($dir); + } + } + } catch (Exception $e) { + // Ignore cleanup errors + } + + // Check if ZIP file was uploaded + if (empty($_FILES['zipFile']['tmp_name'])) { + $response['MESSAGE'] = 'No ZIP file uploaded'; + jsonResponse($response); + } + + // Generate unique folder name + $uniqueId = strtolower(str_replace('-', '', generateUUID())); + $extractDir = "$tempBaseDir/$uniqueId"; + + // Validate it's a ZIP file + $fileExt = strtolower(pathinfo($_FILES['zipFile']['name'], PATHINFO_EXTENSION)); + if ($fileExt !== 'zip') { + $response['MESSAGE'] = 'Only ZIP files are accepted'; + jsonResponse($response); + } + + // Create extraction directory + mkdir($extractDir, 0755, true); + + // Extract the ZIP file + $zip = new ZipArchive(); + if ($zip->open($_FILES['zipFile']['tmp_name']) !== true) { + rmdir($extractDir); + $response['MESSAGE'] = 'Could not open ZIP file'; + jsonResponse($response); + } + $zip->extractTo($extractDir); + $zip->close(); + + // SECURITY: Sanitize extracted files + $safeExtensions = ['htm','html','css','js','json','txt','xml','svg','jpg','jpeg','png','gif','webp','ico','woff','woff2','ttf','eot','otf','map']; + $deletedCount = 0; + + $it = new RecursiveDirectoryIterator($extractDir, RecursiveDirectoryIterator::SKIP_DOTS); + $files = new RecursiveIteratorIterator($it); + foreach ($files as $file) { + if ($file->isDir()) continue; + + $filePath = $file->getRealPath(); + + // Delete symlinks + if (is_link($filePath)) { + unlink($filePath); + $deletedCount++; + continue; + } + + // Check extension whitelist + $ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); + if (!in_array($ext, $safeExtensions)) { + unlink($filePath); + $deletedCount++; + } + } + + $response['SANITIZED_COUNT'] = $deletedCount; + + // Make extracted files world-readable + exec("chmod -R o+rX " . escapeshellarg($extractDir) . " 2>/dev/null"); + + // Find the main HTML file + $htmlFiles = []; + + // Top-level HTML files + foreach (glob("$extractDir/*.htm*") as $f) { + $htmlFiles[] = ['name' => basename($f), 'path' => $f, 'depth' => 0]; + } + + // One level deep + foreach (glob("$extractDir/*", GLOB_ONLYDIR) as $subDir) { + foreach (glob("$subDir/*.htm*") as $f) { + $htmlFiles[] = ['name' => basename($f), 'path' => $f, 'depth' => 1]; + } + } + + if (empty($htmlFiles)) { + // Clean up and error + exec("rm -rf " . escapeshellarg($extractDir)); + $response['MESSAGE'] = 'No HTML files found in ZIP'; + jsonResponse($response); + } + + // Priority: index.html at top level > any index.html > top-level html > first found + $htmlFile = null; + + foreach ($htmlFiles as $hf) { + if (strtolower($hf['name']) === 'index.html' && $hf['depth'] === 0) { + $htmlFile = $hf; + break; + } + } + if (!$htmlFile) { + foreach ($htmlFiles as $hf) { + if (strtolower($hf['name']) === 'index.html') { + $htmlFile = $hf; + break; + } + } + } + if (!$htmlFile) { + foreach ($htmlFiles as $hf) { + if ($hf['depth'] === 0) { + $htmlFile = $hf; + break; + } + } + } + if (!$htmlFile) { + $htmlFile = $htmlFiles[0]; + } + + // Build URL path + $relativePath = str_replace($extractDir, '', $htmlFile['path']); + $relativePath = str_replace('\\', '/', $relativePath); + if ($relativePath[0] !== '/') $relativePath = '/' . $relativePath; + + // Determine protocol and host + $forwardedProto = $_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''; + $protocol = ($forwardedProto === 'https' || ($_SERVER['HTTPS'] ?? '') === 'on') ? 'https' : 'http'; + $serverHost = $_SERVER['HTTP_HOST'] ?? 'localhost'; + + $response['OK'] = true; + $response['MESSAGE'] = 'ZIP extracted successfully'; + $response['URL'] = "$protocol://$serverHost/temp/menu-import/$uniqueId$relativePath"; + $response['FOLDER'] = $uniqueId; + $response['FILE'] = $htmlFile['name']; + $response['FILE_COUNT'] = count($htmlFiles); + +} catch (Exception $e) { + $response['OK'] = false; + $response['MESSAGE'] = 'Error: ' . $e->getMessage(); +} + +jsonResponse($response); diff --git a/api/stations/delete.php b/api/stations/delete.php new file mode 100644 index 0000000..a1288b6 --- /dev/null +++ b/api/stations/delete.php @@ -0,0 +1,24 @@ + false, 'ERROR' => 'missing_businessid', 'MESSAGE' => 'BusinessID is required.']); +} +if ($stationId <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'missing_stationid', 'MESSAGE' => 'StationID is required.']); +} + +try { + queryTimed("UPDATE Stations SET IsActive = 0 WHERE ID = ? AND BusinessID = ?", [$stationId, $bizId]); + queryTimed("UPDATE Items SET StationID = 0 WHERE StationID = ? AND BusinessID = ?", [$stationId, $bizId]); + + jsonResponse(['OK' => true, 'ERROR' => '', 'StationID' => $stationId]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => 'Failed to delete station', 'DETAIL' => $e->getMessage()]); +} diff --git a/api/stations/list.php b/api/stations/list.php new file mode 100644 index 0000000..a39ac69 --- /dev/null +++ b/api/stations/list.php @@ -0,0 +1,35 @@ + false, 'ERROR' => 'missing_businessid', 'MESSAGE' => 'BusinessID is required.']); +} + +try { + $rows = queryTimed(" + SELECT ID, BusinessID, Name, Color, SortOrder + FROM Stations + WHERE BusinessID = ? AND IsActive = 1 + ORDER BY SortOrder, ID + ", [$bizId]); + + $stations = []; + foreach ($rows as $r) { + $stations[] = [ + 'StationID' => (int) $r['ID'], + 'BusinessID' => (int) $r['BusinessID'], + 'Name' => $r['Name'], + 'Color' => $r['Color'], + 'SortOrder' => (int) $r['SortOrder'], + ]; + } + + jsonResponse(['OK' => true, 'ERROR' => '', 'STATIONS' => $stations, 'COUNT' => count($stations)]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => 'DB error loading stations', 'DETAIL' => $e->getMessage()]); +} diff --git a/api/stations/save.php b/api/stations/save.php new file mode 100644 index 0000000..e5ed783 --- /dev/null +++ b/api/stations/save.php @@ -0,0 +1,51 @@ + false, 'ERROR' => 'missing_businessid', 'MESSAGE' => 'BusinessID is required.']); +} +if ($name === '') { + apiAbort(['OK' => false, 'ERROR' => 'missing_name', 'MESSAGE' => 'Station name is required.']); +} + +try { + if ($stationId > 0) { + queryTimed("UPDATE Stations SET Name = ?, Color = ? WHERE ID = ? AND BusinessID = ?", + [$name, $color, $stationId, $bizId]); + } else { + queryTimed(" + INSERT INTO Stations (BusinessID, Name, Color, SortOrder, IsActive, AddedOn) + VALUES (?, ?, ?, + (SELECT COALESCE(MAX(s2.SortOrder), 0) + 1 FROM Stations s2 WHERE s2.BusinessID = ?), + 1, NOW()) + ", [$bizId, $name, $color, $bizId]); + $stationId = (int) lastInsertId(); + } + + $q = queryOne("SELECT ID, BusinessID, Name, Color, SortOrder FROM Stations WHERE ID = ?", [$stationId]); + + if (!$q) { + apiAbort(['OK' => false, 'ERROR' => 'not_found', 'MESSAGE' => 'Station not found after save.']); + } + + jsonResponse([ + 'OK' => true, + 'ERROR' => '', + 'STATION' => [ + 'StationID' => (int) $q['ID'], + 'Name' => $q['Name'], + 'Color' => $q['Color'], + 'SortOrder' => (int) $q['SortOrder'], + ], + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => 'Failed to save station', 'DETAIL' => $e->getMessage()]); +} diff --git a/api/stripe/createPaymentIntent.php b/api/stripe/createPaymentIntent.php new file mode 100644 index 0000000..d73fd14 --- /dev/null +++ b/api/stripe/createPaymentIntent.php @@ -0,0 +1,280 @@ + false, 'ERROR' => 'BusinessID is required']); + if ($orderID === 0) apiAbort(['OK' => false, 'ERROR' => 'OrderID is required']); + if ($subtotal <= 0) apiAbort(['OK' => false, 'ERROR' => 'Invalid subtotal']); + + $config = getStripeConfig(); + if (empty($config['secretKey'])) apiAbort(['OK' => false, 'ERROR' => 'Stripe is not configured']); + + // Get business Stripe account and fee settings + $qBusiness = queryOne("SELECT StripeAccountID, StripeOnboardingComplete, Name, PayfritFee FROM Businesses WHERE ID = ?", [$businessID]); + if (!$qBusiness) apiAbort(['OK' => false, 'ERROR' => 'Business not found']); + + // Get order details + $qOrder = queryOne(" + SELECT o.DeliveryFee, o.OrderTypeID, o.GrantID, o.GrantOwnerBusinessID, + o.GrantEconomicsType, o.GrantEconomicsValue, o.StripePaymentIntentID, + o.UserID, o.BalanceApplied AS PrevBalanceApplied, + u.StripeCustomerId, u.EmailAddress, u.FirstName, u.LastName, u.Balance + FROM Orders o + LEFT JOIN Users u ON u.ID = o.UserID + WHERE o.ID = ? + ", [$orderID]); + + $deliveryFee = 0; + if ($qOrder && (int) $qOrder['OrderTypeID'] === 3) { + $deliveryFee = (float) ($qOrder['DeliveryFee'] ?? 0); + } + + // SP-SM: Resolve grant economics + $grantOwnerFeeCents = 0; + $grantOwnerBusinessID = 0; + $grantID = 0; + if ($qOrder && (int) ($qOrder['GrantID'] ?? 0) > 0) { + $grantID = (int) $qOrder['GrantID']; + $grantOwnerBusinessID = (int) ($qOrder['GrantOwnerBusinessID'] ?? 0); + $grantEconType = $qOrder['GrantEconomicsType'] ?? 'none'; + $grantEconValue = (float) ($qOrder['GrantEconomicsValue'] ?? 0); + + if ($grantEconType === 'flat_fee' && $grantEconValue > 0) { + $grantOwnerFeeCents = round($grantEconValue * 100); + } elseif ($grantEconType === 'percent_of_orders' && $grantEconValue > 0) { + $grantOwnerFeeCents = round($subtotal * ($grantEconValue / 100) * 100); + } + } + + $hasStripeConnect = (int) ($qBusiness['StripeOnboardingComplete'] ?? 0) === 1 + && !empty(trim($qBusiness['StripeAccountID'] ?? '')); + + // ============================================================ + // FEE CALCULATION (from Businesses.PayfritFee) + // ============================================================ + $payfritFee = $qBusiness['PayfritFee'] ?? 0; + if (!is_numeric($payfritFee) || $payfritFee <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'Business PayfritFee not configured']); + } + $customerFeePercent = (float) $payfritFee; + $businessFeePercent = $customerFeePercent; + $cardFeePercent = 0.029; + $cardFeeFixed = 0.30; + + $payfritCustomerFee = $subtotal * $customerFeePercent; + $payfritBusinessFee = $subtotal * $businessFeePercent; + $totalBeforeCardFee = $subtotal + $tax + $tip + $deliveryFee + $payfritCustomerFee; + + // ============================================================ + // AUTO-APPLY USER BALANCE + // ============================================================ + $balanceApplied = 0; + $userBalance = (float) ($qOrder['Balance'] ?? 0); + $orderUserID = (int) ($qOrder['UserID'] ?? 0); + if ($userBalance > 0 && $orderUserID > 0) { + $balanceApplied = round(min($userBalance, $totalBeforeCardFee) * 100) / 100; + $adjustedTest = (($totalBeforeCardFee - $balanceApplied) + $cardFeeFixed) / (1 - $cardFeePercent); + if ($adjustedTest < 0.50) { + $maxBalance = $totalBeforeCardFee - ((0.50 * (1 - $cardFeePercent)) - $cardFeeFixed); + $balanceApplied = round(max(0, min($userBalance, $maxBalance)) * 100) / 100; + } + if ($balanceApplied > 0) { + queryTimed("UPDATE Orders SET BalanceApplied = ? WHERE ID = ?", [$balanceApplied, $orderID]); + } + } + + $adjustedBeforeCardFee = $totalBeforeCardFee - $balanceApplied; + $totalCustomerPays = ($adjustedBeforeCardFee + $cardFeeFixed) / (1 - $cardFeePercent); + $cardFee = $totalCustomerPays - $adjustedBeforeCardFee; + + $totalAmountCents = round($totalCustomerPays * 100); + $totalPlatformFeeCents = round(($payfritCustomerFee + $payfritBusinessFee) * 100); + + // ============================================================ + // CHECK FOR EXISTING PAYMENTINTENT + // ============================================================ + $existingPiId = $qOrder['StripePaymentIntentID'] ?? ''; + if (!empty(trim($existingPiId))) { + $existingPi = stripeRequest('GET', "https://api.stripe.com/v1/payment_intents/$existingPiId"); + + if (!isset($existingPi['error'])) { + $piStatus = $existingPi['status'] ?? ''; + $reusableStates = ['requires_payment_method', 'requires_confirmation', 'requires_action']; + + if (in_array($piStatus, $reusableStates)) { + if ((int) $existingPi['amount'] !== $totalAmountCents) { + $existingPi = stripeRequest('POST', "https://api.stripe.com/v1/payment_intents/$existingPiId", [ + 'amount' => $totalAmountCents, + ]); + } + + jsonResponse([ + 'OK' => true, + 'CLIENT_SECRET' => $existingPi['client_secret'], + 'PAYMENT_INTENT_ID' => $existingPi['id'], + 'PUBLISHABLE_KEY' => $config['publishableKey'], + 'REUSED' => true, + 'FEE_BREAKDOWN' => [ + 'SUBTOTAL' => $subtotal, 'TAX' => $tax, 'TIP' => $tip, + 'DELIVERY_FEE' => $deliveryFee, 'PAYFRIT_FEE' => $payfritCustomerFee, + 'CARD_FEE' => $cardFee, 'BALANCE_APPLIED' => $balanceApplied, + 'TOTAL' => $totalCustomerPays, + 'TOTAL_BEFORE_BALANCE' => ($balanceApplied > 0) ? $totalCustomerPays + $balanceApplied : 0, + 'GRANT_OWNER_FEE_CENTS' => $grantOwnerFeeCents, + ], + ]); + } elseif ($piStatus === 'succeeded') { + apiAbort(['OK' => false, 'ERROR' => 'already_paid', 'MESSAGE' => 'This order has already been paid.']); + } + } + // Terminal or not found — clear and create new + queryTimed("UPDATE Orders SET StripePaymentIntentID = NULL WHERE ID = ?", [$orderID]); + } + + // ============================================================ + // STRIPE CUSTOMER (for saving payment methods) + // ============================================================ + $stripeCustomerId = ''; + $orderUserID = (int) ($qOrder['UserID'] ?? 0); + + if ($orderUserID > 0) { + $stripeCustomerId = $qOrder['StripeCustomerId'] ?? ''; + + // Validate existing customer + $needNewCustomer = empty(trim($stripeCustomerId)); + if (!$needNewCustomer) { + $validateData = stripeRequest('GET', "https://api.stripe.com/v1/customers/$stripeCustomerId"); + if (isset($validateData['error']) || !isset($validateData['id'])) { + $needNewCustomer = true; + } + } + + if ($needNewCustomer) { + $customerParams = ['metadata[user_id]' => $orderUserID]; + $customerName = trim(($qOrder['FirstName'] ?? '') . ' ' . ($qOrder['LastName'] ?? '')); + if (!empty($customerName)) $customerParams['name'] = $customerName; + if (!empty(trim($qOrder['EmailAddress'] ?? ''))) $customerParams['email'] = $qOrder['EmailAddress']; + + $customerData = stripeRequest('POST', 'https://api.stripe.com/v1/customers', $customerParams); + if (!isset($customerData['error']) && isset($customerData['id'])) { + $stripeCustomerId = $customerData['id']; + queryTimed("UPDATE Users SET StripeCustomerId = ? WHERE ID = ?", [$stripeCustomerId, $orderUserID]); + } + } + } + + // ============================================================ + // CREATE PAYMENTINTENT + // ============================================================ + $piParams = [ + 'amount' => $totalAmountCents, + 'currency' => 'usd', + 'automatic_payment_methods[enabled]' => 'true', + 'metadata[order_id]' => $orderID, + 'metadata[business_id]' => $businessID, + 'description' => "Order #$orderID at " . $qBusiness['Name'], + ]; + + if (!empty(trim($stripeCustomerId))) { + $piParams['customer'] = $stripeCustomerId; + $piParams['setup_future_usage'] = 'off_session'; + } + + if ($hasStripeConnect) { + $effectivePlatformFeeCents = $totalPlatformFeeCents + $grantOwnerFeeCents; + $piParams['application_fee_amount'] = $effectivePlatformFeeCents; + $piParams['transfer_data[destination]'] = $qBusiness['StripeAccountID']; + } + + if ($grantOwnerFeeCents > 0) { + $piParams['metadata[grant_id]'] = $grantID; + $piParams['metadata[grant_owner_business_id]'] = $grantOwnerBusinessID; + $piParams['metadata[grant_owner_fee_cents]'] = $grantOwnerFeeCents; + } + + if (!empty($customerEmail)) { + $piParams['receipt_email'] = $customerEmail; + } + + $piHeaders = ['Idempotency-Key' => "pi-order-$orderID"]; + $piData = stripeRequest('POST', 'https://api.stripe.com/v1/payment_intents', $piParams, $piHeaders); + + if (isset($piData['error'])) { + apiAbort(['OK' => false, 'ERROR' => $piData['error']['message']]); + } + + // Store PaymentIntent ID on order + queryTimed("UPDATE Orders SET StripePaymentIntentID = ? WHERE ID = ?", [$piData['id'], $orderID]); + + // Link PaymentIntent to worker payout ledger + try { + queryTimed(" + UPDATE WorkPayoutLedgers wpl + INNER JOIN Tasks t ON t.ID = wpl.TaskID AND t.OrderID = ? + SET wpl.StripePaymentIntentID = ? + WHERE wpl.Status = 'pending_charge' + AND wpl.StripePaymentIntentID IS NULL + ", [$orderID, $piData['id']]); + } catch (Exception $e) { + // Non-fatal + } + + // ============================================================ + // CREATE CHECKOUT SESSION (for iOS) + // ============================================================ + $checkoutParams = [ + 'mode' => 'payment', + 'line_items[0][price_data][currency]' => 'usd', + 'line_items[0][price_data][product_data][name]' => "Order #$orderID at " . $qBusiness['Name'], + 'line_items[0][price_data][unit_amount]' => $totalAmountCents, + 'line_items[0][quantity]' => '1', + 'success_url' => "payfrit://stripe-redirect?success=true&order_id=$orderID", + 'cancel_url' => "payfrit://stripe-redirect?success=false&error=cancelled&order_id=$orderID", + 'metadata[order_id]' => $orderID, + 'metadata[business_id]' => $businessID, + ]; + + if ($hasStripeConnect) { + $effectivePlatformFeeCents = $totalPlatformFeeCents + $grantOwnerFeeCents; + $checkoutParams['payment_intent_data[application_fee_amount]'] = $effectivePlatformFeeCents; + $checkoutParams['payment_intent_data[transfer_data][destination]'] = $qBusiness['StripeAccountID']; + } + + $checkoutData = stripeRequest('POST', 'https://api.stripe.com/v1/checkout/sessions', $checkoutParams); + $checkoutUrl = $checkoutData['url'] ?? ''; + + jsonResponse([ + 'OK' => true, + 'CLIENT_SECRET' => $piData['client_secret'], + 'PAYMENT_INTENT_ID' => $piData['id'], + 'PUBLISHABLE_KEY' => $config['publishableKey'], + 'CHECKOUT_URL' => $checkoutUrl, + 'FEE_BREAKDOWN' => [ + 'SUBTOTAL' => $subtotal, + 'TAX' => $tax, + 'TIP' => $tip, + 'DELIVERY_FEE' => $deliveryFee, + 'PAYFRIT_FEE' => $payfritCustomerFee, + 'CARD_FEE' => $cardFee, + 'BALANCE_APPLIED' => $balanceApplied, + 'TOTAL' => $totalCustomerPays, + 'TOTAL_BEFORE_BALANCE' => ($balanceApplied > 0) ? $totalCustomerPays + $balanceApplied : 0, + 'GRANT_OWNER_FEE_CENTS' => $grantOwnerFeeCents, + ], + 'STRIPE_CONNECT_ENABLED' => $hasStripeConnect, + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => $e->getMessage(), 'DETAIL' => '']); +} diff --git a/api/stripe/getPaymentConfig.php b/api/stripe/getPaymentConfig.php new file mode 100644 index 0000000..d4998b8 --- /dev/null +++ b/api/stripe/getPaymentConfig.php @@ -0,0 +1,57 @@ + false, 'ERROR' => 'UserID is required']); + + $config = getStripeConfig(); + + $qUser = queryOne("SELECT StripeCustomerId, EmailAddress, FirstName, LastName FROM Users WHERE ID = ?", [$userID]); + if (!$qUser) apiAbort(['OK' => false, 'ERROR' => 'User not found']); + + $stripeCustomerId = $qUser['StripeCustomerId'] ?? ''; + + // Create Stripe Customer if user doesn't have one + if (empty(trim($stripeCustomerId))) { + $customerParams = ['metadata[user_id]' => $userID]; + $customerName = trim(($qUser['FirstName'] ?? '') . ' ' . ($qUser['LastName'] ?? '')); + if (!empty($customerName)) $customerParams['name'] = $customerName; + if (!empty(trim($qUser['EmailAddress'] ?? ''))) $customerParams['email'] = $qUser['EmailAddress']; + + $customerData = stripeRequest('POST', 'https://api.stripe.com/v1/customers', $customerParams); + + if (isset($customerData['error'])) { + apiAbort(['OK' => false, 'ERROR' => 'Failed to create customer: ' . $customerData['error']['message']]); + } + + $stripeCustomerId = $customerData['id']; + queryTimed("UPDATE Users SET StripeCustomerId = ? WHERE ID = ?", [$stripeCustomerId, $userID]); + } + + // Create Ephemeral Key (need raw response for SDK) + $ephemeralRaw = stripeRequestRaw( + 'https://api.stripe.com/v1/ephemeral_keys', + ['customer' => $stripeCustomerId], + ['Stripe-Version' => '2023-10-16'] + ); + + $ephemeralData = json_decode($ephemeralRaw, true); + if (isset($ephemeralData['error'])) { + apiAbort(['OK' => false, 'ERROR' => 'Failed to create ephemeral key: ' . $ephemeralData['error']['message']]); + } + + jsonResponse([ + 'OK' => true, + 'CUSTOMER' => $stripeCustomerId, + 'EPHEMERAL_KEY' => $ephemeralRaw, // Raw JSON for SDK + 'PUBLISHABLE_KEY' => $config['publishableKey'], + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => $e->getMessage(), 'DETAIL' => '']); +} diff --git a/api/stripe/onboard.php b/api/stripe/onboard.php new file mode 100644 index 0000000..d5b2000 --- /dev/null +++ b/api/stripe/onboard.php @@ -0,0 +1,61 @@ + false, 'ERROR' => 'BusinessID is required']); + + $config = getStripeConfig(); + if (empty($config['secretKey'])) apiAbort(['OK' => false, 'ERROR' => 'Stripe is not configured']); + + $qBusiness = queryOne("SELECT StripeAccountID, Name FROM Businesses WHERE ID = ?", [$businessID]); + if (!$qBusiness) apiAbort(['OK' => false, 'ERROR' => 'Business not found']); + + $stripeAccountID = $qBusiness['StripeAccountID'] ?? ''; + + // Create new connected account if none exists + if (empty($stripeAccountID)) { + $accountData = stripeRequest('POST', 'https://api.stripe.com/v1/accounts', [ + 'type' => 'express', + 'country' => 'US', + 'capabilities[card_payments][requested]' => 'true', + 'capabilities[transfers][requested]' => 'true', + 'business_profile[name]' => $qBusiness['Name'], + ]); + + if (isset($accountData['error'])) { + apiAbort(['OK' => false, 'ERROR' => $accountData['error']['message']]); + } + + $stripeAccountID = $accountData['id']; + + queryTimed("UPDATE Businesses SET StripeAccountID = ?, StripeOnboardingStarted = NOW() WHERE ID = ?", + [$stripeAccountID, $businessID]); + } + + // Create account link for onboarding + $base = baseUrl(); + $linkData = stripeRequest('POST', 'https://api.stripe.com/v1/account_links', [ + 'account' => $stripeAccountID, + 'refresh_url' => $base . '/portal/index.html?stripe=retry', + 'return_url' => $base . '/portal/index.html?stripe=complete', + 'type' => 'account_onboarding', + ]); + + if (isset($linkData['error'])) { + apiAbort(['OK' => false, 'ERROR' => $linkData['error']['message']]); + } + + jsonResponse([ + 'OK' => true, + 'ONBOARDING_URL' => $linkData['url'], + 'STRIPE_ACCOUNT_ID' => $stripeAccountID, + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => $e->getMessage()]); +} diff --git a/api/stripe/status.php b/api/stripe/status.php new file mode 100644 index 0000000..ca1ff3c --- /dev/null +++ b/api/stripe/status.php @@ -0,0 +1,71 @@ + false, 'ERROR' => 'BusinessID is required']); + + $config = getStripeConfig(); + + $qBusiness = queryOne("SELECT StripeAccountID, StripeOnboardingComplete FROM Businesses WHERE ID = ?", [$businessID]); + if (!$qBusiness) apiAbort(['OK' => false, 'ERROR' => 'Business not found']); + + $stripeAccountID = $qBusiness['StripeAccountID'] ?? ''; + + if (empty($stripeAccountID)) { + jsonResponse(['OK' => true, 'CONNECTED' => false, 'ACCOUNT_STATUS' => 'not_started']); + } + + if (!empty($config['secretKey'])) { + $accountData = stripeRequest('GET', "https://api.stripe.com/v1/accounts/$stripeAccountID"); + + if (isset($accountData['error'])) { + jsonResponse([ + 'OK' => true, + 'CONNECTED' => false, + 'ACCOUNT_STATUS' => 'error', + 'ERROR_DETAIL' => $accountData['error']['message'], + ]); + } + + $chargesEnabled = $accountData['charges_enabled'] ?? false; + $payoutsEnabled = $accountData['payouts_enabled'] ?? false; + $detailsSubmitted = $accountData['details_submitted'] ?? false; + + if ($chargesEnabled && $payoutsEnabled) { + $accountStatus = 'active'; + if (!$qBusiness['StripeOnboardingComplete']) { + queryTimed("UPDATE Businesses SET StripeOnboardingComplete = 1 WHERE ID = ?", [$businessID]); + } + } elseif ($detailsSubmitted) { + $accountStatus = 'pending_verification'; + } else { + $accountStatus = 'incomplete'; + } + + jsonResponse([ + 'OK' => true, + 'CONNECTED' => $chargesEnabled && $payoutsEnabled, + 'ACCOUNT_STATUS' => $accountStatus, + 'STRIPE_ACCOUNT_ID' => $stripeAccountID, + 'CHARGES_ENABLED' => $chargesEnabled, + 'PAYOUTS_ENABLED' => $payoutsEnabled, + 'DETAILS_SUBMITTED' => $detailsSubmitted, + ]); + } + + // No Stripe key, return what we have in DB + jsonResponse([ + 'OK' => true, + 'CONNECTED' => (int) $qBusiness['StripeOnboardingComplete'] === 1, + 'ACCOUNT_STATUS' => (int) $qBusiness['StripeOnboardingComplete'] === 1 ? 'active' : 'unknown', + 'STRIPE_ACCOUNT_ID' => $stripeAccountID, + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => $e->getMessage()]); +} diff --git a/api/stripe/webhook.php b/api/stripe/webhook.php new file mode 100644 index 0000000..55c77de --- /dev/null +++ b/api/stripe/webhook.php @@ -0,0 +1,293 @@ + false, 'ERROR' => 'invalid_signature']); + } + + // Check timestamp tolerance (5 minutes) + if (abs(time() - (int) $sigTimestamp) > 300) { + jsonResponse(['OK' => false, 'ERROR' => 'timestamp_expired']); + } + + $signedPayload = $sigTimestamp . '.' . $payload; + $expectedSig = hash_hmac('sha256', $signedPayload, $webhookSecret); + + if (!hash_equals($expectedSig, strtolower($sigV1))) { + jsonResponse(['OK' => false, 'ERROR' => 'signature_mismatch']); + } + } + + $event = json_decode($payload, true); + $eventType = $event['type'] ?? ''; + $eventData = $event['data']['object'] ?? []; + + switch ($eventType) { + + case 'payment_intent.succeeded': + $paymentIntentID = $eventData['id']; + $orderID = (int) ($eventData['metadata']['order_id'] ?? 0); + $metaType = $eventData['metadata']['type'] ?? ''; + + // === TAB CLOSE CAPTURE === + if ($metaType === 'tab_close') { + $tabID = (int) ($eventData['metadata']['tab_id'] ?? 0); + if ($tabID > 0) { + queryTimed(" + UPDATE Tabs SET StatusID = 3, PaymentStatus = 'captured', CapturedOn = NOW(), ClosedOn = NOW() + WHERE ID = ? AND StatusID = 2 + ", [$tabID]); + + $qTabOrders = queryTimed("SELECT OrderID FROM TabOrders WHERE TabID = ? AND ApprovalStatus = 'approved'", [$tabID]); + foreach ($qTabOrders as $row) { + queryTimed("UPDATE Orders SET PaymentStatus = 'paid', PaymentCompletedOn = NOW() WHERE ID = ?", [$row['OrderID']]); + } + } + break; + } + + if ($orderID > 0) { + // Mark order as paid + queryTimed(" + UPDATE Orders + SET PaymentStatus = 'paid', PaymentCompletedOn = NOW(), + SubmittedOn = COALESCE(SubmittedOn, NOW()), + StatusID = CASE WHEN StatusID = 0 THEN 1 ELSE StatusID END + WHERE ID = ? + ", [$orderID]); + + // Deduct user balance if applied at checkout + $qBalOrder = queryOne("SELECT BalanceApplied, UserID FROM Orders WHERE ID = ?", [$orderID]); + if ($qBalOrder && (float) ($qBalOrder['BalanceApplied'] ?? 0) > 0) { + queryTimed("UPDATE Users SET Balance = GREATEST(Balance - ?, 0) WHERE ID = ?", + [(float) $qBalOrder['BalanceApplied'], (int) $qBalOrder['UserID']]); + } + + // Create kitchen task + $qOrder = queryOne(" + SELECT o.BusinessID, o.ServicePointID, sp.Name AS ServicePointName + FROM Orders o + LEFT JOIN ServicePoints sp ON sp.ID = o.ServicePointID + WHERE o.ID = ? + ", [$orderID]); + + if ($qOrder) { + $tableName = !empty(trim($qOrder['ServicePointName'] ?? '')) ? $qOrder['ServicePointName'] : 'Table'; + $taskTitle = "Prepare Order #$orderID for $tableName"; + + queryTimed(" + INSERT INTO Tasks (BusinessID, OrderID, ServicePointID, Title, CreatedOn, ClaimedByUserID) + VALUES (?, ?, ?, ?, NOW(), 0) + ", [$qOrder['BusinessID'], $orderID, (int) ($qOrder['ServicePointID'] ?? 0), $taskTitle]); + } + } + + // === WORKER PAYOUT TRANSFER === + try { + $qLedger = queryOne(" + SELECT ID, UserID, TaskID, NetTransferCents, StripeTransferID, Status + FROM WorkPayoutLedgers + WHERE StripePaymentIntentID = ? LIMIT 1 + ", [$paymentIntentID]); + + if ($qLedger && empty($qLedger['StripeTransferID']) && $qLedger['Status'] === 'pending_charge') { + queryTimed("UPDATE WorkPayoutLedgers SET Status = 'charged', UpdatedAt = NOW() WHERE ID = ?", + [$qLedger['ID']]); + + $qWorker = queryOne("SELECT StripeConnectedAccountID FROM Users WHERE ID = ?", [$qLedger['UserID']]); + $workerAccountID = $qWorker['StripeConnectedAccountID'] ?? ''; + + if (!empty(trim($workerAccountID)) && (int) $qLedger['NetTransferCents'] > 0) { + $transferData = stripeRequest('POST', 'https://api.stripe.com/v1/transfers', [ + 'amount' => $qLedger['NetTransferCents'], + 'currency' => 'usd', + 'destination' => $workerAccountID, + 'metadata[user_id]' => $qLedger['UserID'], + 'metadata[task_id]' => $qLedger['TaskID'], + 'metadata[ledger_id]' => $qLedger['ID'], + 'metadata[activation_withheld_cents]' => 0, + ], ['Idempotency-Key' => 'transfer-ledger-' . $qLedger['ID']]); + + if (isset($transferData['id'])) { + queryTimed("UPDATE WorkPayoutLedgers SET StripeTransferID = ?, Status = 'transferred', UpdatedAt = NOW() WHERE ID = ?", + [$transferData['id'], $qLedger['ID']]); + } + } elseif ((int) $qLedger['NetTransferCents'] === 0) { + queryTimed("UPDATE WorkPayoutLedgers SET Status = 'transferred', UpdatedAt = NOW() WHERE ID = ?", + [$qLedger['ID']]); + } + } + } catch (Exception $e) { + // Non-fatal + } + + // === SP-SM: GRANT OWNER TRANSFER === + try { + $grantOwnerFeeCents = (int) ($eventData['metadata']['grant_owner_fee_cents'] ?? 0); + $grantOwnerBizID = (int) ($eventData['metadata']['grant_owner_business_id'] ?? 0); + $grantMetaID = (int) ($eventData['metadata']['grant_id'] ?? 0); + + if ($grantOwnerFeeCents > 0 && $grantOwnerBizID > 0) { + $qOwnerBiz = queryOne("SELECT StripeAccountID FROM Businesses WHERE ID = ?", [$grantOwnerBizID]); + $ownerStripeAcct = $qOwnerBiz['StripeAccountID'] ?? ''; + + if (!empty(trim($ownerStripeAcct))) { + stripeRequest('POST', 'https://api.stripe.com/v1/transfers', [ + 'amount' => $grantOwnerFeeCents, + 'currency' => 'usd', + 'destination' => $ownerStripeAcct, + 'metadata[grant_id]' => $grantMetaID, + 'metadata[order_id]' => $orderID, + 'metadata[type]' => 'grant_owner_fee', + ], ['Idempotency-Key' => "grant-transfer-$orderID-$grantMetaID"]); + } + } + } catch (Exception $e) { + // Non-fatal + } + break; + + case 'payment_intent.payment_failed': + $orderID = (int) ($eventData['metadata']['order_id'] ?? 0); + $failureMessage = $eventData['last_payment_error']['message'] ?? 'Payment failed'; + + if ($orderID > 0) { + queryTimed("UPDATE Orders SET PaymentStatus = 'failed', PaymentError = ? WHERE ID = ?", + [$failureMessage, $orderID]); + } + break; + + case 'charge.refunded': + $paymentIntentID = $eventData['payment_intent'] ?? ''; + $refundAmount = ($eventData['amount_refunded'] ?? 0) / 100; + + if (!empty($paymentIntentID)) { + queryTimed(" + UPDATE Orders SET PaymentStatus = 'refunded', RefundAmount = ?, RefundedOn = NOW() + WHERE StripePaymentIntentID = ? + ", [$refundAmount, $paymentIntentID]); + } + break; + + case 'charge.dispute.created': + $paymentIntentID = $eventData['payment_intent'] ?? ''; + + if (!empty($paymentIntentID)) { + $qOrder = queryOne("SELECT ID, BusinessID FROM Orders WHERE StripePaymentIntentID = ?", [$paymentIntentID]); + + if ($qOrder) { + queryTimed("UPDATE Orders SET PaymentStatus = 'disputed' WHERE StripePaymentIntentID = ?", + [$paymentIntentID]); + + queryTimed(" + INSERT INTO Tasks (BusinessID, CategoryID, Title, Details, CreatedOn, StatusID, SourceType, SourceID) + VALUES (?, 4, 'Payment Dispute', ?, NOW(), 0, 'dispute', ?) + ", [ + $qOrder['BusinessID'], + 'Order #' . $qOrder['ID'] . ' has been disputed. Review immediately.', + $qOrder['ID'], + ]); + } + } + break; + + case 'account.updated': + $accountID = $eventData['id']; + $chargesEnabled = $eventData['charges_enabled'] ?? false; + $payoutsEnabled = $eventData['payouts_enabled'] ?? false; + + // Business accounts + if ($chargesEnabled && $payoutsEnabled) { + queryTimed("UPDATE Businesses SET StripeOnboardingComplete = 1 WHERE StripeAccountID = ?", [$accountID]); + } + + // Worker accounts + try { + $qWorkerAcct = queryOne("SELECT ID FROM Users WHERE StripeConnectedAccountID = ? LIMIT 1", [$accountID]); + if ($qWorkerAcct) { + queryTimed("UPDATE Users SET StripePayoutsEnabled = ? WHERE StripeConnectedAccountID = ?", + [$payoutsEnabled ? 1 : 0, $accountID]); + } + } catch (Exception $e) { + // Non-fatal + } + break; + + case 'checkout.session.completed': + try { + $sessionMetadata = $eventData['metadata'] ?? []; + $metaType = $sessionMetadata['type'] ?? ''; + $metaUserID = (int) ($sessionMetadata['user_id'] ?? 0); + + // Activation early unlock + if ($metaType === 'activation_unlock' && $metaUserID > 0) { + queryTimed("UPDATE Users SET ActivationBalanceCents = ActivationCapCents WHERE ID = ?", [$metaUserID]); + } + + // Tip payment + if ($metaType === 'tip') { + $metaTipID = (int) ($sessionMetadata['tip_id'] ?? 0); + $metaWorkerID = (int) ($sessionMetadata['worker_user_id'] ?? 0); + $tipPaymentIntent = $eventData['payment_intent'] ?? ''; + + if ($metaTipID > 0) { + queryTimed("UPDATE Tips SET Status = 'paid', PaidOn = NOW(), StripePaymentIntentID = ? WHERE ID = ? AND Status = 'pending'", + [$tipPaymentIntent, $metaTipID]); + + // Transfer tip to worker + if ($metaWorkerID > 0) { + $qTipWorker = queryOne("SELECT StripeConnectedAccountID FROM Users WHERE ID = ?", [$metaWorkerID]); + $tipWorkerAcct = $qTipWorker['StripeConnectedAccountID'] ?? ''; + + if (!empty(trim($tipWorkerAcct))) { + $qTipAmt = queryOne("SELECT AmountCents FROM Tips WHERE ID = ?", [$metaTipID]); + + if ($qTipAmt && (int) $qTipAmt['AmountCents'] > 0) { + $tipTransferData = stripeRequest('POST', 'https://api.stripe.com/v1/transfers', [ + 'amount' => $qTipAmt['AmountCents'], + 'currency' => 'usd', + 'destination' => $tipWorkerAcct, + 'metadata[type]' => 'tip', + 'metadata[tip_id]' => $metaTipID, + 'metadata[worker_user_id]' => $metaWorkerID, + ], ['Idempotency-Key' => "tip-transfer-$metaTipID"]); + + if (isset($tipTransferData['id'])) { + queryTimed("UPDATE Tips SET Status = 'transferred', StripeTransferID = ? WHERE ID = ?", + [$tipTransferData['id'], $metaTipID]); + } + } + } + } + } + } + } catch (Exception $e) { + // Non-fatal + } + break; + } + + jsonResponse(['OK' => true, 'RECEIVED' => true]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => $e->getMessage()]); +} diff --git a/api/tabs/addMember.php b/api/tabs/addMember.php new file mode 100644 index 0000000..aa8c171 --- /dev/null +++ b/api/tabs/addMember.php @@ -0,0 +1,61 @@ + false, 'ERROR' => 'missing_TabID']); + if ($ownerUserID === 0) apiAbort(['OK' => false, 'ERROR' => 'missing_OwnerUserID']); + if ($targetUserID === 0) apiAbort(['OK' => false, 'ERROR' => 'missing_TargetUserID']); + + $qTab = queryOne(" + SELECT t.ID, t.OwnerUserID, t.StatusID, t.BusinessID, b.TabMaxMembers + FROM Tabs t JOIN Businesses b ON b.ID = t.BusinessID + WHERE t.ID = ? LIMIT 1 + ", [$tabID]); + + if (!$qTab) apiAbort(['OK' => false, 'ERROR' => 'tab_not_found']); + if ((int) $qTab['StatusID'] !== 1) apiAbort(['OK' => false, 'ERROR' => 'tab_not_open']); + if ((int) $qTab['OwnerUserID'] !== $ownerUserID) apiAbort(['OK' => false, 'ERROR' => 'not_owner']); + + // Check member limit + $qCount = queryOne("SELECT COUNT(*) AS Cnt FROM TabMembers WHERE TabID = ? AND StatusID = 1", [$tabID]); + if ((int) $qCount['Cnt'] >= (int) ($qTab['TabMaxMembers'] ?? 0)) { + apiAbort(['OK' => false, 'ERROR' => 'max_members', 'MESSAGE' => 'Tab has reached the maximum number of members.']); + } + + // Check target not already on any tab + $qExisting = queryOne(" + SELECT t.ID, b.Name AS BusinessName + FROM TabMembers tm JOIN Tabs t ON t.ID = tm.TabID JOIN Businesses b ON b.ID = t.BusinessID + WHERE tm.UserID = ? AND tm.StatusID = 1 AND t.StatusID = 1 LIMIT 1 + ", [$targetUserID]); + if ($qExisting) apiAbort(['OK' => false, 'ERROR' => 'user_already_on_tab', 'MESSAGE' => 'This user is already on a tab.']); + + // Check target user exists + $qTarget = queryOne("SELECT FirstName, LastName FROM Users WHERE ID = ? LIMIT 1", [$targetUserID]); + if (!$qTarget) apiAbort(['OK' => false, 'ERROR' => 'user_not_found']); + + queryTimed(" + INSERT INTO TabMembers (TabID, UserID, RoleID, StatusID, JoinedOn) + VALUES (?, ?, 2, 1, NOW()) + ON DUPLICATE KEY UPDATE StatusID = 1, LeftOn = NULL, JoinedOn = NOW() + ", [$tabID, $targetUserID]); + + jsonResponse([ + 'OK' => true, + 'MEMBER' => [ + 'UserID' => $targetUserID, + 'FirstName' => $qTarget['FirstName'], + 'LastName' => $qTarget['LastName'], + 'RoleID' => 2, + ], + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/tabs/addOrder.php b/api/tabs/addOrder.php new file mode 100644 index 0000000..65fc906 --- /dev/null +++ b/api/tabs/addOrder.php @@ -0,0 +1,103 @@ + false, 'ERROR' => 'missing_TabID']); + if ($orderID === 0) apiAbort(['OK' => false, 'ERROR' => 'missing_OrderID']); + if ($userID === 0) apiAbort(['OK' => false, 'ERROR' => 'missing_UserID']); + + $qTab = queryOne(" + SELECT t.ID, t.StatusID, t.BusinessID, t.OwnerUserID, t.AuthAmountCents, t.RunningTotalCents, + t.ApprovalMode, b.TabApprovalRequired, b.TabAutoIncreaseThreshold + FROM Tabs t JOIN Businesses b ON b.ID = t.BusinessID + WHERE t.ID = ? LIMIT 1 + ", [$tabID]); + + if (!$qTab) apiAbort(['OK' => false, 'ERROR' => 'tab_not_found']); + if ((int) $qTab['StatusID'] !== 1) apiAbort(['OK' => false, 'ERROR' => 'tab_not_open']); + + // Verify user is a member + $qMember = queryOne("SELECT RoleID FROM TabMembers WHERE TabID = ? AND UserID = ? AND StatusID = 1 LIMIT 1", [$tabID, $userID]); + if (!$qMember) apiAbort(['OK' => false, 'ERROR' => 'not_a_member']); + $isOwner = (int) $qMember['RoleID'] === 1; + + // Verify order + $qOrder = queryOne("SELECT ID, BusinessID, StatusID, UserID FROM Orders WHERE ID = ? LIMIT 1", [$orderID]); + if (!$qOrder) apiAbort(['OK' => false, 'ERROR' => 'order_not_found']); + if ((int) $qOrder['BusinessID'] !== (int) $qTab['BusinessID']) apiAbort(['OK' => false, 'ERROR' => 'wrong_business']); + if ((int) $qOrder['StatusID'] !== 0) apiAbort(['OK' => false, 'ERROR' => 'order_not_in_cart', 'MESSAGE' => 'Order must be in cart state.']); + + // Calculate order subtotal + tax + $qTotals = queryOne(" + SELECT COALESCE(SUM(oli.Price * oli.Quantity), 0) AS Subtotal + FROM OrderLineItems oli WHERE oli.OrderID = ? AND oli.IsDeleted = 0 + ", [$orderID]); + + $subtotal = (float) $qTotals['Subtotal']; + $subtotalCents = round($subtotal * 100); + + $qBizTax = queryOne("SELECT TaxRate FROM Businesses WHERE ID = ?", [$qTab['BusinessID']]); + $taxRate = (float) ($qBizTax['TaxRate'] ?? 0); + $taxCents = round($subtotalCents * $taxRate); + + // Determine approval status + $approvalMode = $qTab['ApprovalMode'] ?? ''; + $requiresApproval = (is_numeric($approvalMode) && $approvalMode !== '') + ? (int) $approvalMode === 1 + : (int) ($qTab['TabApprovalRequired'] ?? 0) === 1; + $approvalStatus = (!$isOwner && $requiresApproval) ? 'pending' : 'approved'; + + // Check authorization limit for auto-approved orders + $newRunning = (int) $qTab['RunningTotalCents']; + if ($approvalStatus === 'approved') { + $newRunning = (int) $qTab['RunningTotalCents'] + $subtotalCents + $taxCents; + if ($newRunning > (int) $qTab['AuthAmountCents']) { + apiAbort([ + 'OK' => false, 'ERROR' => 'exceeds_authorization', + 'MESSAGE' => 'This order would exceed your tab authorization. Please increase your authorization first.', + 'RUNNING_TOTAL_CENTS' => (int) $qTab['RunningTotalCents'], + 'ORDER_CENTS' => $subtotalCents + $taxCents, + 'AUTH_AMOUNT_CENTS' => (int) $qTab['AuthAmountCents'], + ]); + } + } + + // Link order to tab + queryTimed("UPDATE Orders SET TabID = ? WHERE ID = ?", [$tabID, $orderID]); + + queryTimed(" + INSERT INTO TabOrders (TabID, OrderID, UserID, ApprovalStatus, SubtotalCents, TaxCents, AddedOn) + VALUES (?, ?, ?, ?, ?, ?, NOW()) + ON DUPLICATE KEY UPDATE ApprovalStatus = VALUES(ApprovalStatus), SubtotalCents = VALUES(SubtotalCents), TaxCents = VALUES(TaxCents) + ", [$tabID, $orderID, $userID, $approvalStatus, $subtotalCents, $taxCents]); + + if ($approvalStatus === 'approved') { + queryTimed("UPDATE Tabs SET RunningTotalCents = ?, LastActivityOn = NOW() WHERE ID = ?", [$newRunning, $tabID]); + } + + // Check auto-increase threshold + $needsIncrease = false; + $threshold = (float) ($qTab['TabAutoIncreaseThreshold'] ?? 0); + if ($threshold > 0 && (int) $qTab['AuthAmountCents'] > 0) { + $needsIncrease = ($newRunning / (int) $qTab['AuthAmountCents']) >= $threshold; + } + + jsonResponse([ + 'OK' => true, + 'APPROVAL_STATUS' => $approvalStatus, + 'RUNNING_TOTAL_CENTS' => $newRunning, + 'AUTH_REMAINING_CENTS' => (int) $qTab['AuthAmountCents'] - $newRunning, + 'NEEDS_INCREASE' => $needsIncrease, + 'ORDER_SUBTOTAL_CENTS' => $subtotalCents, + 'ORDER_TAX_CENTS' => $taxCents, + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/tabs/approveOrder.php b/api/tabs/approveOrder.php new file mode 100644 index 0000000..9f162c7 --- /dev/null +++ b/api/tabs/approveOrder.php @@ -0,0 +1,64 @@ + false, 'ERROR' => 'missing_TabID']); + if ($orderID === 0) apiAbort(['OK' => false, 'ERROR' => 'missing_OrderID']); + if ($userID === 0) apiAbort(['OK' => false, 'ERROR' => 'missing_UserID']); + + $qTab = queryOne(" + SELECT ID, OwnerUserID, StatusID, AuthAmountCents, RunningTotalCents + FROM Tabs WHERE ID = ? LIMIT 1 + ", [$tabID]); + + if (!$qTab) apiAbort(['OK' => false, 'ERROR' => 'tab_not_found']); + if ((int) $qTab['StatusID'] !== 1) apiAbort(['OK' => false, 'ERROR' => 'tab_not_open']); + if ((int) $qTab['OwnerUserID'] !== $userID) apiAbort(['OK' => false, 'ERROR' => 'not_owner']); + + $qTabOrder = queryOne(" + SELECT ID, SubtotalCents, TaxCents, ApprovalStatus + FROM TabOrders WHERE TabID = ? AND OrderID = ? LIMIT 1 + ", [$tabID, $orderID]); + + if (!$qTabOrder) apiAbort(['OK' => false, 'ERROR' => 'order_not_on_tab']); + if ($qTabOrder['ApprovalStatus'] !== 'pending') apiAbort(['OK' => false, 'ERROR' => 'not_pending', 'MESSAGE' => "Order is {$qTabOrder['ApprovalStatus']}, not pending."]); + + // Check authorization limit + $orderTotal = (int) $qTabOrder['SubtotalCents'] + (int) $qTabOrder['TaxCents']; + $newRunning = (int) $qTab['RunningTotalCents'] + $orderTotal; + if ($newRunning > (int) $qTab['AuthAmountCents']) { + apiAbort([ + 'OK' => false, 'ERROR' => 'exceeds_authorization', + 'MESSAGE' => 'Approving this order would exceed your tab authorization. Increase your authorization first.', + 'RUNNING_TOTAL_CENTS' => (int) $qTab['RunningTotalCents'], + 'ORDER_CENTS' => $orderTotal, + 'AUTH_AMOUNT_CENTS' => (int) $qTab['AuthAmountCents'], + ]); + } + + queryTimed("UPDATE TabOrders SET ApprovalStatus = 'approved', ApprovedByUserID = ?, ApprovedOn = NOW() WHERE TabID = ? AND OrderID = ?", + [$userID, $tabID, $orderID]); + + queryTimed("UPDATE Tabs SET RunningTotalCents = ?, LastActivityOn = NOW() WHERE ID = ?", [$newRunning, $tabID]); + + // Auto-submit order to kitchen + $qOrder = queryOne("SELECT StatusID FROM Orders WHERE ID = ? LIMIT 1", [$orderID]); + if ($qOrder && (int) $qOrder['StatusID'] === 0) { + queryTimed("UPDATE Orders SET StatusID = 1, SubmittedOn = NOW(), LastEditedOn = NOW() WHERE ID = ?", [$orderID]); + } + + jsonResponse([ + 'OK' => true, + 'RUNNING_TOTAL_CENTS' => $newRunning, + 'AUTH_REMAINING_CENTS' => (int) $qTab['AuthAmountCents'] - $newRunning, + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/tabs/cancel.php b/api/tabs/cancel.php new file mode 100644 index 0000000..55dbce6 --- /dev/null +++ b/api/tabs/cancel.php @@ -0,0 +1,41 @@ + false, 'ERROR' => 'missing_TabID']); + if ($userID === 0) apiAbort(['OK' => false, 'ERROR' => 'missing_UserID']); + + $qTab = queryOne("SELECT ID, OwnerUserID, StatusID, StripePaymentIntentID, BusinessID FROM Tabs WHERE ID = ? LIMIT 1", [$tabID]); + if (!$qTab) apiAbort(['OK' => false, 'ERROR' => 'tab_not_found']); + if ((int) $qTab['StatusID'] !== 1) apiAbort(['OK' => false, 'ERROR' => 'tab_not_open']); + if ((int) $qTab['OwnerUserID'] !== $userID) apiAbort(['OK' => false, 'ERROR' => 'not_owner']); + + // Check for approved orders + $qApproved = queryOne(" + SELECT COUNT(*) AS Cnt, COALESCE(SUM(SubtotalCents), 0) AS TotalCents + FROM TabOrders WHERE TabID = ? AND ApprovalStatus = 'approved' + ", [$tabID]); + + if ((int) $qApproved['Cnt'] > 0 && (int) $qApproved['TotalCents'] > 0) { + apiAbort(['OK' => false, 'ERROR' => 'has_orders', 'MESSAGE' => 'Tab has approved orders. Close the tab instead of cancelling.']); + } + + // Cancel Stripe PI + if (!empty(trim($qTab['StripePaymentIntentID'] ?? ''))) { + stripeRequest('POST', "https://api.stripe.com/v1/payment_intents/{$qTab['StripePaymentIntentID']}/cancel"); + } + + queryTimed("UPDATE Tabs SET StatusID = 4, ClosedOn = NOW(), PaymentStatus = 'cancelled' WHERE ID = ?", [$tabID]); + queryTimed("UPDATE TabMembers SET StatusID = 3, LeftOn = NOW() WHERE TabID = ? AND StatusID = 1", [$tabID]); + + jsonResponse(['OK' => true, 'MESSAGE' => 'Tab cancelled. Card hold released.']); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/tabs/close.php b/api/tabs/close.php new file mode 100644 index 0000000..795a9bf --- /dev/null +++ b/api/tabs/close.php @@ -0,0 +1,140 @@ + false, 'ERROR' => 'missing_TabID']); + if ($userID === 0) apiAbort(['OK' => false, 'ERROR' => 'missing_UserID']); + + $qTab = queryOne(" + SELECT t.*, b.PayfritFee, b.StripeAccountID, b.StripeOnboardingComplete + FROM Tabs t JOIN Businesses b ON b.ID = t.BusinessID + WHERE t.ID = ? LIMIT 1 + ", [$tabID]); + + if (!$qTab) apiAbort(['OK' => false, 'ERROR' => 'tab_not_found']); + if ((int) $qTab['StatusID'] !== 1) apiAbort(['OK' => false, 'ERROR' => 'tab_not_open', 'MESSAGE' => "Tab is not open (status: {$qTab['StatusID']})."]); + + // Verify: must be tab owner or business employee + $isTabOwner = (int) $qTab['OwnerUserID'] === $userID; + if (!$isTabOwner) { + $qEmp = queryOne("SELECT ID FROM Employees WHERE BusinessID = ? AND UserID = ? AND IsActive = 1 LIMIT 1", + [$qTab['BusinessID'], $userID]); + if (!$qEmp) apiAbort(['OK' => false, 'ERROR' => 'not_authorized', 'MESSAGE' => 'Only the tab owner or a business employee can close the tab.']); + } + + // Reject pending orders + queryTimed("UPDATE TabOrders SET ApprovalStatus = 'rejected' WHERE TabID = ? AND ApprovalStatus = 'pending'", [$tabID]); + + // Calculate aggregate totals + $qTotals = queryOne(" + SELECT COALESCE(SUM(SubtotalCents), 0) AS TotalSubtotalCents, + COALESCE(SUM(TaxCents), 0) AS TotalTaxCents + FROM TabOrders WHERE TabID = ? AND ApprovalStatus = 'approved' + ", [$tabID]); + + $totalSubtotalCents = (int) $qTotals['TotalSubtotalCents']; + $totalTaxCents = (int) $qTotals['TotalTaxCents']; + + // If no orders, just cancel + if ($totalSubtotalCents === 0) { + stripeRequest('POST', "https://api.stripe.com/v1/payment_intents/{$qTab['StripePaymentIntentID']}/cancel"); + queryTimed("UPDATE Tabs SET StatusID = 4, ClosedOn = NOW(), PaymentStatus = 'cancelled' WHERE ID = ?", [$tabID]); + jsonResponse(['OK' => true, 'MESSAGE' => 'Tab cancelled (no orders).', 'FINAL_CAPTURE_CENTS' => 0]); + } + + // Calculate tip + $tipCents = 0; + $tipPct = 0; + if ($tipPercent >= 0) { + $tipPct = $tipPercent; + $tipCents = round($totalSubtotalCents * $tipPercent); + } elseif ($tipAmount >= 0) { + $tipCents = round($tipAmount * 100); + if ($totalSubtotalCents > 0) $tipPct = $tipCents / $totalSubtotalCents; + } + + // Fee calculation + $payfritFeeRate = (float) ($qTab['PayfritFee'] ?? 0); + if ($payfritFeeRate <= 0) apiAbort(['OK' => false, 'ERROR' => 'no_fee_configured', 'MESSAGE' => 'Business PayfritFee not set.']); + + $payfritFeeCents = round($totalSubtotalCents * $payfritFeeRate); + $totalBeforeCardFeeCents = $totalSubtotalCents + $totalTaxCents + $tipCents + $payfritFeeCents; + + $cardFeeFixedCents = 30; + $cardFeePercent = 0.029; + $totalWithCardFeeCents = (int) ceil(($totalBeforeCardFeeCents + $cardFeeFixedCents) / (1 - $cardFeePercent)); + $cardFeeCents = $totalWithCardFeeCents - $totalBeforeCardFeeCents; + + $finalCaptureCents = $totalWithCardFeeCents; + $applicationFeeCents = $payfritFeeCents * 2; + + // Ensure capture doesn't exceed authorization + if ($finalCaptureCents > (int) $qTab['AuthAmountCents']) { + $finalCaptureCents = (int) $qTab['AuthAmountCents']; + if ($totalWithCardFeeCents > 0) { + $applicationFeeCents = round($applicationFeeCents * ($finalCaptureCents / $totalWithCardFeeCents)); + } + } + + // Mark tab as closing + queryTimed("UPDATE Tabs SET StatusID = 2 WHERE ID = ?", [$tabID]); + + // Capture the PaymentIntent + $captureParams = [ + 'amount_to_capture' => $finalCaptureCents, + 'metadata[type]' => 'tab_close', + 'metadata[tab_id]' => $tabID, + 'metadata[tip_cents]' => $tipCents, + ]; + if (!empty(trim($qTab['StripeAccountID'] ?? '')) && (int) ($qTab['StripeOnboardingComplete'] ?? 0) === 1) { + $captureParams['application_fee_amount'] = $applicationFeeCents; + } + + $captureData = stripeRequest('POST', "https://api.stripe.com/v1/payment_intents/{$qTab['StripePaymentIntentID']}/capture", $captureParams); + + if (($captureData['status'] ?? '') === 'succeeded') { + queryTimed(" + UPDATE Tabs SET StatusID = 3, ClosedOn = NOW(), CapturedOn = NOW(), + TipAmountCents = ?, FinalCaptureCents = ?, RunningTotalCents = ?, PaymentStatus = 'captured' + WHERE ID = ? + ", [$tipCents, $finalCaptureCents, $totalSubtotalCents + $totalTaxCents, $tabID]); + + queryTimed(" + UPDATE Orders o JOIN TabOrders tbo ON tbo.OrderID = o.ID + SET o.PaymentStatus = 'paid', o.PaymentCompletedOn = NOW() + WHERE tbo.TabID = ? AND tbo.ApprovalStatus = 'approved' + ", [$tabID]); + + queryTimed("UPDATE TabMembers SET StatusID = 3, LeftOn = NOW() WHERE TabID = ? AND StatusID = 1", [$tabID]); + + jsonResponse([ + 'OK' => true, + 'FINAL_CAPTURE_CENTS' => $finalCaptureCents, + 'TAB_UUID' => $qTab['UUID'], + 'FEE_BREAKDOWN' => [ + 'SUBTOTAL_CENTS' => $totalSubtotalCents, + 'TAX_CENTS' => $totalTaxCents, + 'TIP_CENTS' => $tipCents, + 'TIP_PERCENT' => $tipPct, + 'PAYFRIT_FEE_CENTS' => $payfritFeeCents, + 'CARD_FEE_CENTS' => $cardFeeCents, + 'TOTAL_CENTS' => $finalCaptureCents, + ], + ]); + } else { + $errMsg = $captureData['error']['message'] ?? 'Capture failed'; + queryTimed("UPDATE Tabs SET PaymentStatus = 'capture_failed', PaymentError = ? WHERE ID = ?", [$errMsg, $tabID]); + apiAbort(['OK' => false, 'ERROR' => 'capture_failed', 'MESSAGE' => $errMsg]); + } + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/tabs/get.php b/api/tabs/get.php new file mode 100644 index 0000000..9b44f09 --- /dev/null +++ b/api/tabs/get.php @@ -0,0 +1,110 @@ + false, 'ERROR' => 'missing_TabID']); + if ($userID === 0) apiAbort(['OK' => false, 'ERROR' => 'missing_UserID']); + + // Verify user is a member + $qMember = queryOne("SELECT RoleID FROM TabMembers WHERE TabID = ? AND UserID = ? AND StatusID = 1 LIMIT 1", [$tabID, $userID]); + if (!$qMember) apiAbort(['OK' => false, 'ERROR' => 'not_a_member']); + $isOwner = (int) $qMember['RoleID'] === 1; + + // Get tab + $qTab = queryOne(" + SELECT t.*, b.Name AS BusinessName, b.PayfritFee, b.TaxRate, + sp.Name AS ServicePointName, + u.FirstName AS OwnerFirstName, u.LastName AS OwnerLastName + FROM Tabs t + JOIN Businesses b ON b.ID = t.BusinessID + LEFT JOIN ServicePoints sp ON sp.ID = t.ServicePointID + JOIN Users u ON u.ID = t.OwnerUserID + WHERE t.ID = ? LIMIT 1 + ", [$tabID]); + if (!$qTab) apiAbort(['OK' => false, 'ERROR' => 'tab_not_found']); + + // Get members + $qMembers = queryTimed(" + SELECT tm.ID, tm.UserID, tm.RoleID, tm.StatusID, tm.JoinedOn, + u.FirstName, u.LastName, u.ImageExtension + FROM TabMembers tm JOIN Users u ON u.ID = tm.UserID + WHERE tm.TabID = ? AND tm.StatusID = 1 + ORDER BY tm.RoleID, tm.JoinedOn + ", [$tabID]); + + $members = []; + foreach ($qMembers as $row) { + $members[] = [ + 'UserID' => (int) $row['UserID'], + 'FirstName' => $row['FirstName'], + 'LastName' => $row['LastName'], + 'RoleID' => (int) $row['RoleID'], + 'IsOwner' => (int) $row['RoleID'] === 1, + 'ImageExtension' => $row['ImageExtension'] ?? '', + 'JoinedOn' => toISO8601($row['JoinedOn']), + ]; + } + + // Get orders + $qOrders = queryTimed(" + SELECT tbo.OrderID, tbo.UserID, tbo.ApprovalStatus, tbo.SubtotalCents, tbo.TaxCents, tbo.AddedOn, + o.StatusID AS OrderStatusID, o.UUID AS OrderUUID, + u.FirstName, u.LastName + FROM TabOrders tbo + JOIN Orders o ON o.ID = tbo.OrderID + JOIN Users u ON u.ID = tbo.UserID + WHERE tbo.TabID = ? + ORDER BY tbo.AddedOn DESC + ", [$tabID]); + + $orders = []; + foreach ($qOrders as $row) { + if (!$isOwner && (int) $row['UserID'] !== $userID) continue; + $orders[] = [ + 'OrderID' => (int) $row['OrderID'], + 'OrderUUID' => $row['OrderUUID'], + 'UserID' => (int) $row['UserID'], + 'UserName' => $row['FirstName'] . ' ' . $row['LastName'], + 'ApprovalStatus' => $row['ApprovalStatus'], + 'SubtotalCents' => (int) $row['SubtotalCents'], + 'TaxCents' => (int) $row['TaxCents'], + 'OrderStatusID' => (int) $row['OrderStatusID'], + 'AddedOn' => toISO8601($row['AddedOn']), + ]; + } + + jsonResponse([ + 'OK' => true, + 'TAB' => [ + 'ID' => (int) $qTab['ID'], + 'UUID' => $qTab['UUID'], + 'BusinessID' => (int) $qTab['BusinessID'], + 'BusinessName' => $qTab['BusinessName'], + 'OwnerUserID' => (int) $qTab['OwnerUserID'], + 'OwnerName' => $qTab['OwnerFirstName'] . ' ' . $qTab['OwnerLastName'], + 'ServicePointID' => (int) ($qTab['ServicePointID'] ?? 0), + 'ServicePointName' => $qTab['ServicePointName'] ?? '', + 'StatusID' => (int) $qTab['StatusID'], + 'AuthAmountCents' => (int) $qTab['AuthAmountCents'], + 'RunningTotalCents' => (int) $qTab['RunningTotalCents'], + 'RemainingCents' => (int) $qTab['AuthAmountCents'] - (int) $qTab['RunningTotalCents'], + 'TipAmountCents' => (int) ($qTab['TipAmountCents'] ?? 0), + 'FinalCaptureCents' => (int) ($qTab['FinalCaptureCents'] ?? 0), + 'PaymentStatus' => $qTab['PaymentStatus'] ?? '', + 'OpenedOn' => toISO8601($qTab['OpenedOn']), + 'ClosedOn' => !empty($qTab['ClosedOn']) ? toISO8601($qTab['ClosedOn']) : '', + 'IsOwner' => $isOwner, + 'PayfritFee' => (float) ($qTab['PayfritFee'] ?? 0), + 'TaxRate' => (float) ($qTab['TaxRate'] ?? 0), + ], + 'MEMBERS' => $members, + 'ORDERS' => $orders, + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/tabs/getActive.php b/api/tabs/getActive.php new file mode 100644 index 0000000..6d52673 --- /dev/null +++ b/api/tabs/getActive.php @@ -0,0 +1,68 @@ + false, 'ERROR' => 'missing_UserID']); + + $qTab = queryOne(" + SELECT t.ID, t.UUID, t.BusinessID, t.OwnerUserID, t.ServicePointID, + t.StatusID, t.AuthAmountCents, t.RunningTotalCents, + t.OpenedOn, t.LastActivityOn, t.PaymentStatus, t.ApprovalMode, + b.Name AS BusinessName, b.TabApprovalRequired, + tm.RoleID, + sp.Name AS ServicePointName, + u.FirstName AS OwnerFirstName, u.LastName AS OwnerLastName + FROM TabMembers tm + JOIN Tabs t ON t.ID = tm.TabID + JOIN Businesses b ON b.ID = t.BusinessID + LEFT JOIN ServicePoints sp ON sp.ID = t.ServicePointID + JOIN Users u ON u.ID = t.OwnerUserID + WHERE tm.UserID = ? AND tm.StatusID = 1 AND t.StatusID = 1 + LIMIT 1 + ", [$userID]); + + if (!$qTab) jsonResponse(['OK' => true, 'HAS_TAB' => false]); + + $qMembers = queryOne("SELECT COUNT(*) AS MemberCount FROM TabMembers WHERE TabID = ? AND StatusID = 1", [$qTab['ID']]); + + $pendingCount = 0; + if ((int) $qTab['RoleID'] === 1) { + $qPending = queryOne("SELECT COUNT(*) AS PendingCount FROM TabOrders WHERE TabID = ? AND ApprovalStatus = 'pending'", [$qTab['ID']]); + $pendingCount = (int) $qPending['PendingCount']; + } + + $approvalMode = $qTab['ApprovalMode'] ?? ''; + $approvalRequired = (is_numeric($approvalMode) && $approvalMode !== '') + ? (int) $approvalMode === 1 + : (int) ($qTab['TabApprovalRequired'] ?? 0) === 1; + + jsonResponse([ + 'OK' => true, + 'HAS_TAB' => true, + 'TAB' => [ + 'ID' => (int) $qTab['ID'], + 'UUID' => $qTab['UUID'], + 'BusinessID' => (int) $qTab['BusinessID'], + 'BusinessName' => $qTab['BusinessName'], + 'OwnerUserID' => (int) $qTab['OwnerUserID'], + 'OwnerName' => $qTab['OwnerFirstName'] . ' ' . $qTab['OwnerLastName'], + 'ServicePointID' => (int) ($qTab['ServicePointID'] ?? 0), + 'ServicePointName' => $qTab['ServicePointName'] ?? '', + 'StatusID' => (int) $qTab['StatusID'], + 'AuthAmountCents' => (int) $qTab['AuthAmountCents'], + 'RunningTotalCents' => (int) $qTab['RunningTotalCents'], + 'RemainingCents' => (int) $qTab['AuthAmountCents'] - (int) $qTab['RunningTotalCents'], + 'OpenedOn' => toISO8601($qTab['OpenedOn']), + 'MemberCount' => (int) $qMembers['MemberCount'], + 'PendingOrderCount' => $pendingCount, + 'IsOwner' => (int) $qTab['RoleID'] === 1, + 'ApprovalRequired' => $approvalRequired, + ], + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/tabs/getPresence.php b/api/tabs/getPresence.php new file mode 100644 index 0000000..3166cc1 --- /dev/null +++ b/api/tabs/getPresence.php @@ -0,0 +1,65 @@ + false, 'ERROR' => 'missing_BusinessID']); + + if ($servicePointID > 0) { + $qPresence = queryTimed(" + SELECT up.UserID, up.ServicePointID, up.LastSeenOn, + u.FirstName, u.LastName, u.ImageExtension, + sp.Name AS ServicePointName + FROM UserPresence up + JOIN Users u ON u.ID = up.UserID + LEFT JOIN ServicePoints sp ON sp.ID = up.ServicePointID + WHERE up.BusinessID = ? AND up.ServicePointID = ? + AND up.LastSeenOn >= DATE_SUB(NOW(), INTERVAL 30 MINUTE) + AND up.UserID != ? + ORDER BY up.LastSeenOn DESC + ", [$businessID, $servicePointID, $userID]); + } else { + $qPresence = queryTimed(" + SELECT up.UserID, up.ServicePointID, up.LastSeenOn, + u.FirstName, u.LastName, u.ImageExtension, + sp.Name AS ServicePointName + FROM UserPresence up + JOIN Users u ON u.ID = up.UserID + LEFT JOIN ServicePoints sp ON sp.ID = up.ServicePointID + WHERE up.BusinessID = ? + AND up.LastSeenOn >= DATE_SUB(NOW(), INTERVAL 30 MINUTE) + AND up.UserID != ? + ORDER BY up.LastSeenOn DESC + ", [$businessID, $userID]); + } + + $users = []; + foreach ($qPresence as $row) { + $qOnTab = queryOne(" + SELECT t.ID AS TabID FROM TabMembers tm + JOIN Tabs t ON t.ID = tm.TabID + WHERE tm.UserID = ? AND tm.StatusID = 1 AND t.StatusID = 1 LIMIT 1 + ", [$row['UserID']]); + + $users[] = [ + 'UserID' => (int) $row['UserID'], + 'FirstName' => $row['FirstName'], + 'LastName' => $row['LastName'], + 'ImageExtension' => $row['ImageExtension'] ?? '', + 'ServicePointID' => (int) ($row['ServicePointID'] ?? 0), + 'ServicePointName' => $row['ServicePointName'] ?? '', + 'LastSeenOn' => toISO8601($row['LastSeenOn']), + 'IsOnTab' => $qOnTab !== null, + ]; + } + + jsonResponse(['OK' => true, 'USERS' => $users]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/tabs/increaseAuth.php b/api/tabs/increaseAuth.php new file mode 100644 index 0000000..0d75c9b --- /dev/null +++ b/api/tabs/increaseAuth.php @@ -0,0 +1,52 @@ + false, 'ERROR' => 'missing_TabID']); + if ($userID === 0) apiAbort(['OK' => false, 'ERROR' => 'missing_UserID']); + if ($newAuthAmount <= 0) apiAbort(['OK' => false, 'ERROR' => 'missing_NewAuthAmount']); + + $qTab = queryOne(" + SELECT t.ID, t.OwnerUserID, t.StatusID, t.AuthAmountCents, t.StripePaymentIntentID, + b.TabMaxAuthAmount + FROM Tabs t JOIN Businesses b ON b.ID = t.BusinessID + WHERE t.ID = ? LIMIT 1 + ", [$tabID]); + + if (!$qTab) apiAbort(['OK' => false, 'ERROR' => 'tab_not_found']); + if ((int) $qTab['StatusID'] !== 1) apiAbort(['OK' => false, 'ERROR' => 'tab_not_open']); + if ((int) $qTab['OwnerUserID'] !== $userID) apiAbort(['OK' => false, 'ERROR' => 'not_owner']); + + $newAuthCents = round($newAuthAmount * 100); + $maxCents = round((float) ($qTab['TabMaxAuthAmount'] ?? 0) * 100); + + if ($newAuthCents <= (int) $qTab['AuthAmountCents']) apiAbort(['OK' => false, 'ERROR' => 'not_an_increase', 'MESSAGE' => 'New amount must be higher than current authorization.']); + if ($maxCents > 0 && $newAuthCents > $maxCents) apiAbort(['OK' => false, 'ERROR' => 'exceeds_max', 'MESSAGE' => "Maximum authorization is \$" . number_format((float) $qTab['TabMaxAuthAmount'], 2) . "."]); + + // Update Stripe PI amount + $updateData = stripeRequest('POST', "https://api.stripe.com/v1/payment_intents/{$qTab['StripePaymentIntentID']}", [ + 'amount' => $newAuthCents, + ]); + + if (isset($updateData['id']) && (int) ($updateData['amount'] ?? 0) === $newAuthCents) { + queryTimed("UPDATE Tabs SET AuthAmountCents = ? WHERE ID = ?", [$newAuthCents, $tabID]); + jsonResponse([ + 'OK' => true, + 'AUTH_AMOUNT_CENTS' => $newAuthCents, + 'PREVIOUS_AUTH_CENTS' => (int) $qTab['AuthAmountCents'], + ]); + } else { + $errMsg = $updateData['error']['message'] ?? 'Authorization increase declined'; + apiAbort(['OK' => false, 'ERROR' => 'increase_declined', 'MESSAGE' => $errMsg, 'CURRENT_AUTH_CENTS' => (int) $qTab['AuthAmountCents']]); + } + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/tabs/open.php b/api/tabs/open.php new file mode 100644 index 0000000..6bcf781 --- /dev/null +++ b/api/tabs/open.php @@ -0,0 +1,142 @@ + 0) { + $authAmount = (int) $data['AuthAmountCents'] / 100; + } else { + $authAmount = (float) ($data['AuthAmount'] ?? 0); + } + $servicePointID = (int) ($data['ServicePointID'] ?? 0); + $approvalMode = isset($data['ApprovalMode']) ? (int) $data['ApprovalMode'] : null; + + if ($userID === 0) apiAbort(['OK' => false, 'ERROR' => 'missing_UserID']); + if ($businessID === 0) apiAbort(['OK' => false, 'ERROR' => 'missing_BusinessID']); + + $qBiz = queryOne(" + SELECT SessionEnabled, TabMinAuthAmount, TabDefaultAuthAmount, TabMaxAuthAmount, + StripeAccountID, StripeOnboardingComplete + FROM Businesses WHERE ID = ? LIMIT 1 + ", [$businessID]); + + if (!$qBiz) apiAbort(['OK' => false, 'ERROR' => 'business_not_found']); + if (!$qBiz['SessionEnabled']) apiAbort(['OK' => false, 'ERROR' => 'tabs_not_enabled', 'MESSAGE' => 'This business does not accept tabs.']); + + $minAuth = (float) $qBiz['TabMinAuthAmount']; + $maxAuth = (float) $qBiz['TabMaxAuthAmount']; + if ($authAmount <= 0) $authAmount = (float) $qBiz['TabDefaultAuthAmount']; + if ($authAmount < $minAuth) apiAbort(['OK' => false, 'ERROR' => 'auth_too_low', 'MESSAGE' => "Minimum authorization is \$" . number_format($minAuth, 2), 'MIN' => $minAuth]); + if ($authAmount > $maxAuth) apiAbort(['OK' => false, 'ERROR' => 'auth_too_high', 'MESSAGE' => "Maximum authorization is \$" . number_format($maxAuth, 2), 'MAX' => $maxAuth]); + + // Check user not already on a tab + $qExisting = queryOne(" + SELECT t.ID, t.BusinessID, b.Name AS BusinessName + FROM TabMembers tm + JOIN Tabs t ON t.ID = tm.TabID + JOIN Businesses b ON b.ID = t.BusinessID + WHERE tm.UserID = ? AND tm.StatusID = 1 AND t.StatusID = 1 + LIMIT 1 + ", [$userID]); + + if ($qExisting) { + apiAbort([ + 'OK' => false, 'ERROR' => 'already_on_tab', + 'MESSAGE' => "You're already on a tab at " . $qExisting['BusinessName'] . ".", + 'EXISTING_TAB_ID' => (int) $qExisting['ID'], + 'EXISTING_BUSINESS_NAME' => $qExisting['BusinessName'], + ]); + } + + // Get or create Stripe Customer + $qUser = queryOne("SELECT StripeCustomerId, EmailAddress, FirstName, LastName FROM Users WHERE ID = ? LIMIT 1", [$userID]); + if (!$qUser) apiAbort(['OK' => false, 'ERROR' => 'user_not_found']); + + $stripeCustomerId = $qUser['StripeCustomerId'] ?? ''; + + $needNewCustomer = empty(trim($stripeCustomerId)); + if (!$needNewCustomer) { + $checkData = stripeRequest('GET', "https://api.stripe.com/v1/customers/$stripeCustomerId"); + if (isset($checkData['error']) || !isset($checkData['id'])) { + $needNewCustomer = true; + } + } + + if ($needNewCustomer) { + $custParams = [ + 'name' => trim($qUser['FirstName'] . ' ' . $qUser['LastName']), + 'metadata[payfrit_user_id]' => $userID, + ]; + if (!empty(trim($qUser['EmailAddress'] ?? ''))) $custParams['email'] = $qUser['EmailAddress']; + + $custData = stripeRequest('POST', 'https://api.stripe.com/v1/customers', $custParams); + if (isset($custData['id'])) { + $stripeCustomerId = $custData['id']; + queryTimed("UPDATE Users SET StripeCustomerId = ? WHERE ID = ?", [$stripeCustomerId, $userID]); + } else { + apiAbort(['OK' => false, 'ERROR' => 'stripe_customer_failed', 'MESSAGE' => 'Could not create Stripe customer.']); + } + } + + $tabUUID = generateUUID(); + $authAmountCents = round($authAmount * 100); + + // Create PaymentIntent with manual capture + $piParams = [ + 'amount' => $authAmountCents, + 'currency' => 'usd', + 'capture_method' => 'manual', + 'customer' => $stripeCustomerId, + 'setup_future_usage' => 'off_session', + 'automatic_payment_methods[enabled]' => 'true', + 'metadata[type]' => 'tab_authorization', + 'metadata[tab_uuid]' => $tabUUID, + 'metadata[business_id]' => $businessID, + 'metadata[user_id]' => $userID, + ]; + + if (!empty(trim($qBiz['StripeAccountID'] ?? '')) && (int) ($qBiz['StripeOnboardingComplete'] ?? 0) === 1) { + $piParams['transfer_data[destination]'] = $qBiz['StripeAccountID']; + } + + $now = gmdate('YmdHis'); + $piData = stripeRequest('POST', 'https://api.stripe.com/v1/payment_intents', $piParams, + ['Idempotency-Key' => "tab-open-$userID-$businessID-$now"]); + + if (!isset($piData['id'])) { + $errMsg = $piData['error']['message'] ?? 'Stripe error'; + apiAbort(['OK' => false, 'ERROR' => 'stripe_pi_failed', 'MESSAGE' => $errMsg]); + } + + // Insert tab + $spVal = $servicePointID > 0 ? $servicePointID : null; + queryTimed(" + INSERT INTO Tabs (UUID, BusinessID, OwnerUserID, ServicePointID, StatusID, + AuthAmountCents, StripePaymentIntentID, StripeCustomerID, ApprovalMode, OpenedOn, LastActivityOn) + VALUES (?, ?, ?, ?, 1, ?, ?, ?, ?, NOW(), NOW()) + ", [$tabUUID, $businessID, $userID, $spVal, $authAmountCents, $piData['id'], $stripeCustomerId, $approvalMode]); + + $tabID = (int) lastInsertId(); + + // Add owner as TabMember + queryTimed("INSERT INTO TabMembers (TabID, UserID, RoleID, StatusID, JoinedOn) VALUES (?, ?, 1, 1, NOW())", [$tabID, $userID]); + + $config = getStripeConfig(); + jsonResponse([ + 'OK' => true, + 'TAB_ID' => $tabID, + 'TAB_UUID' => $tabUUID, + 'CLIENT_SECRET' => $piData['client_secret'], + 'PAYMENT_INTENT_ID' => $piData['id'], + 'AUTH_AMOUNT_CENTS' => $authAmountCents, + 'PUBLISHABLE_KEY' => $config['publishableKey'], + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/tabs/pendingOrders.php b/api/tabs/pendingOrders.php new file mode 100644 index 0000000..788b8c3 --- /dev/null +++ b/api/tabs/pendingOrders.php @@ -0,0 +1,59 @@ + false, 'ERROR' => 'missing_TabID']); + if ($userID === 0) apiAbort(['OK' => false, 'ERROR' => 'missing_UserID']); + + $qTab = queryOne("SELECT OwnerUserID FROM Tabs WHERE ID = ? LIMIT 1", [$tabID]); + if (!$qTab) apiAbort(['OK' => false, 'ERROR' => 'tab_not_found']); + if ((int) $qTab['OwnerUserID'] !== $userID) apiAbort(['OK' => false, 'ERROR' => 'not_owner']); + + $qPending = queryTimed(" + SELECT tbo.OrderID, tbo.UserID, tbo.SubtotalCents, tbo.TaxCents, tbo.AddedOn, + u.FirstName, u.LastName + FROM TabOrders tbo JOIN Users u ON u.ID = tbo.UserID + WHERE tbo.TabID = ? AND tbo.ApprovalStatus = 'pending' + ORDER BY tbo.AddedOn + ", [$tabID]); + + $orders = []; + foreach ($qPending as $row) { + $qItems = queryTimed(" + SELECT oli.ID, oli.ItemID, oli.Price, oli.Quantity, oli.Remark, + i.Name AS ItemName + FROM OrderLineItems oli JOIN Items i ON i.ID = oli.ItemID + WHERE oli.OrderID = ? AND oli.IsDeleted = 0 AND oli.ParentOrderLineItemID = 0 + ", [$row['OrderID']]); + + $items = []; + foreach ($qItems as $item) { + $items[] = [ + 'Name' => $item['ItemName'], + 'Price' => (float) $item['Price'], + 'Quantity' => (int) $item['Quantity'], + 'Remark' => $item['Remark'] ?? '', + ]; + } + + $orders[] = [ + 'OrderID' => (int) $row['OrderID'], + 'UserID' => (int) $row['UserID'], + 'UserName' => $row['FirstName'] . ' ' . $row['LastName'], + 'SubtotalCents' => (int) $row['SubtotalCents'], + 'TaxCents' => (int) $row['TaxCents'], + 'AddedOn' => toISO8601($row['AddedOn']), + 'Items' => $items, + ]; + } + + jsonResponse(['OK' => true, 'PENDING_ORDERS' => $orders]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/tabs/rejectOrder.php b/api/tabs/rejectOrder.php new file mode 100644 index 0000000..449cf31 --- /dev/null +++ b/api/tabs/rejectOrder.php @@ -0,0 +1,30 @@ + false, 'ERROR' => 'missing_TabID']); + if ($orderID === 0) apiAbort(['OK' => false, 'ERROR' => 'missing_OrderID']); + if ($userID === 0) apiAbort(['OK' => false, 'ERROR' => 'missing_UserID']); + + $qTab = queryOne("SELECT OwnerUserID, StatusID FROM Tabs WHERE ID = ? LIMIT 1", [$tabID]); + if (!$qTab) apiAbort(['OK' => false, 'ERROR' => 'tab_not_found']); + if ((int) $qTab['OwnerUserID'] !== $userID) apiAbort(['OK' => false, 'ERROR' => 'not_owner']); + + $qTabOrder = queryOne("SELECT ApprovalStatus FROM TabOrders WHERE TabID = ? AND OrderID = ? LIMIT 1", [$tabID, $orderID]); + if (!$qTabOrder) apiAbort(['OK' => false, 'ERROR' => 'order_not_on_tab']); + if ($qTabOrder['ApprovalStatus'] !== 'pending') apiAbort(['OK' => false, 'ERROR' => 'not_pending']); + + queryTimed("UPDATE TabOrders SET ApprovalStatus = 'rejected' WHERE TabID = ? AND OrderID = ?", [$tabID, $orderID]); + queryTimed("UPDATE Orders SET TabID = NULL WHERE ID = ?", [$orderID]); + + jsonResponse(['OK' => true]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/tabs/removeMember.php b/api/tabs/removeMember.php new file mode 100644 index 0000000..8574295 --- /dev/null +++ b/api/tabs/removeMember.php @@ -0,0 +1,32 @@ + false, 'ERROR' => 'missing_TabID']); + if ($ownerUserID === 0) apiAbort(['OK' => false, 'ERROR' => 'missing_OwnerUserID']); + if ($targetUserID === 0) apiAbort(['OK' => false, 'ERROR' => 'missing_TargetUserID']); + + $qTab = queryOne("SELECT OwnerUserID, StatusID FROM Tabs WHERE ID = ? LIMIT 1", [$tabID]); + if (!$qTab) apiAbort(['OK' => false, 'ERROR' => 'tab_not_found']); + if ((int) $qTab['StatusID'] !== 1) apiAbort(['OK' => false, 'ERROR' => 'tab_not_open']); + if ((int) $qTab['OwnerUserID'] !== $ownerUserID) apiAbort(['OK' => false, 'ERROR' => 'not_owner']); + if ($targetUserID === $ownerUserID) apiAbort(['OK' => false, 'ERROR' => 'cannot_remove_self']); + + // Reject pending orders from this member + queryTimed("UPDATE TabOrders SET ApprovalStatus = 'rejected' WHERE TabID = ? AND UserID = ? AND ApprovalStatus = 'pending'", + [$tabID, $targetUserID]); + + queryTimed("UPDATE TabMembers SET StatusID = 2, LeftOn = NOW() WHERE TabID = ? AND UserID = ? AND StatusID = 1", + [$tabID, $targetUserID]); + + jsonResponse(['OK' => true]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/tasks/accept.php b/api/tasks/accept.php new file mode 100644 index 0000000..a1bb51e --- /dev/null +++ b/api/tasks/accept.php @@ -0,0 +1,53 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'TaskID is required.']); +} + +try { + global $userId; + + // Verify task exists and is unclaimed + $qTask = queryOne(" + SELECT ID, ClaimedByUserID, BusinessID, OrderID + FROM Tasks + WHERE ID = ? + ", [$taskID]); + + if (!$qTask) { + apiAbort(['OK' => false, 'ERROR' => 'not_found', 'MESSAGE' => 'Task not found.']); + } + + if ((int) $qTask['ClaimedByUserID'] > 0) { + apiAbort(['OK' => false, 'ERROR' => 'already_accepted', 'MESSAGE' => 'Task has already been claimed.']); + } + + // Update task to claimed + queryTimed(" + UPDATE Tasks + SET ClaimedByUserID = ?, + ClaimedOn = NOW() + WHERE ID = ? + AND ClaimedByUserID = 0 + ", [$userId > 0 ? $userId : 1, $taskID]); + + jsonResponse([ + 'OK' => true, + 'ERROR' => '', + 'MESSAGE' => 'Task claimed successfully.', + 'TaskID' => $taskID, + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => 'Error claiming task', 'DETAIL' => $e->getMessage()]); +} diff --git a/api/tasks/callServer.php b/api/tasks/callServer.php new file mode 100644 index 0000000..2404c7b --- /dev/null +++ b/api/tasks/callServer.php @@ -0,0 +1,94 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'BusinessID is required']); +} + +try { + // If servicePointID not provided but orderID is, look it up from the order + if ($servicePointID <= 0 && $orderID > 0) { + $qOrderSP = queryOne("SELECT ServicePointID FROM Orders WHERE ID = ?", [$orderID]); + if ($qOrderSP && (int) $qOrderSP['ServicePointID'] > 0) { + $servicePointID = (int) $qOrderSP['ServicePointID']; + } + } + + if ($servicePointID <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'missing_params', 'MESSAGE' => 'ServicePointID is required']); + } + + // Get service point info + $spQuery = queryOne("SELECT Name FROM ServicePoints WHERE ID = ?", [$servicePointID]); + $tableName = $spQuery ? $spQuery['Name'] : "Table #$servicePointID"; + + // Get user name if available + $userName = ''; + if ($userID > 0) { + $userQuery = queryOne("SELECT FirstName FROM Users WHERE ID = ?", [$userID]); + if ($userQuery && !empty(trim($userQuery['FirstName']))) { + $userName = $userQuery['FirstName']; + } + } + + // Get task type name if TaskTypeID provided + $taskTypeName = ''; + if ($taskTypeID > 0) { + $typeQuery = queryOne("SELECT Name FROM tt_TaskTypes WHERE ID = ?", [$taskTypeID]); + if ($typeQuery && !empty(trim($typeQuery['Name']))) { + $taskTypeName = $typeQuery['Name']; + } + } + + // Create task title and details + $taskTitle = !empty($taskTypeName) + ? "$taskTypeName - $tableName" + : "Service Request - $tableName"; + + $taskDetails = ''; + if (!empty($taskTypeName)) $taskDetails .= "Task: $taskTypeName\n"; + if (!empty($userName)) $taskDetails .= "Customer: $userName\n"; + $taskDetails .= "Location: $tableName\n"; + $taskDetails .= !empty($message) ? "Request: $message" : "Customer is requesting assistance"; + + // Insert task + queryTimed(" + INSERT INTO Tasks ( + BusinessID, ServicePointID, UserID, OrderID, TaskTypeID, + Title, Details, ClaimedByUserID, CreatedOn + ) VALUES (?, ?, ?, ?, ?, ?, ?, 0, NOW()) + ", [ + $businessID, + $servicePointID, + $userID > 0 ? $userID : null, + $orderID > 0 ? $orderID : null, + $taskTypeID, + $taskTitle, + $taskDetails, + ]); + + $taskID = lastInsertId(); + + jsonResponse([ + 'OK' => true, + 'TASK_ID' => (int) $taskID, + 'MESSAGE' => 'Staff has been notified', + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/tasks/complete.php b/api/tasks/complete.php new file mode 100644 index 0000000..82d712c --- /dev/null +++ b/api/tasks/complete.php @@ -0,0 +1,365 @@ + 0 ? $userId : (int) ($data['UserID'] ?? 0); + +$workerRating = $data['workerRating'] ?? []; +$cashReceivedCents = (int) ($data['CashReceivedCents'] ?? 0); +$cancelOrder = !empty($data['CancelOrder']) && $data['CancelOrder'] === true; + +if ($taskID <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'missing_params', 'MESSAGE' => 'TaskID is required.']); +} + +try { + // Verify task exists + $qTask = queryOne(" + SELECT t.ID, t.ClaimedByUserID, t.CompletedOn, t.OrderID, t.TaskTypeID, t.BusinessID, + o.UserID AS CustomerUserID, o.ServicePointID, + tt.Name AS TaskTypeName, + b.UserID AS BusinessOwnerUserID, + COALESCE(emp.RoleID, 1) AS WorkerRoleID + FROM Tasks t + LEFT JOIN Orders o ON o.ID = t.OrderID + LEFT JOIN tt_TaskTypes tt ON tt.ID = t.TaskTypeID + LEFT JOIN Businesses b ON b.ID = t.BusinessID + LEFT JOIN Employees emp ON emp.BusinessID = t.BusinessID AND emp.UserID = t.ClaimedByUserID AND emp.IsActive = 1 + WHERE t.ID = ? + ", [$taskID]); + + if (!$qTask) { + apiAbort(['OK' => false, 'ERROR' => 'not_found', 'MESSAGE' => 'Task not found.']); + } + + $isChatTask = ((int) $qTask['TaskTypeID'] === 2); + $isCashTask = (!empty(trim($qTask['TaskTypeName'] ?? '')) && stripos($qTask['TaskTypeName'], 'Cash') !== false); + $isAdminRole = ((int) $qTask['WorkerRoleID'] >= 2); + + if (!$isChatTask && (int) $qTask['ClaimedByUserID'] === 0) { + apiAbort(['OK' => false, 'ERROR' => 'not_claimed', 'MESSAGE' => 'Task has not been claimed yet.']); + } + + if (!$isChatTask && $userID > 0 && (int) $qTask['ClaimedByUserID'] !== $userID) { + apiAbort(['OK' => false, 'ERROR' => 'not_yours', 'MESSAGE' => 'This task was claimed by someone else.']); + } + + if (!empty(trim($qTask['CompletedOn'] ?? ''))) { + apiAbort(['OK' => false, 'ERROR' => 'already_completed', 'MESSAGE' => 'Task has already been completed.']); + } + + $hasServicePoint = ((int) ($qTask['ServicePointID'] ?? 0) > 0); + $customerUserID = (int) ($qTask['CustomerUserID'] ?? 0); + $businessOwnerUserID = (int) ($qTask['BusinessOwnerUserID'] ?? 0); + $workerUserID = (int) $qTask['ClaimedByUserID']; + $ratingsCreated = []; + $orderCancelled = false; + + // === CASH TASK VALIDATION === + $cashResult = []; + $customerFeeDollars = 0; + $businessFeeDollars = 0; + $payfritRevenueCents = 0; + $orderTotalCents = 0; + $cashOwedCents = 0; + $changeCents = 0; + $businessReceivesCents = 0; + $balanceAppliedCents = 0; + $activationWithhold = 0; + + if ($isCashTask && (int) $qTask['OrderID'] > 0 && !$cancelOrder) { + if ($cashReceivedCents <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'cash_required', 'MESSAGE' => 'Cash amount is required for cash tasks.']); + } + if ($cashReceivedCents > 50000) { + apiAbort(['OK' => false, 'ERROR' => 'cash_limit', 'MESSAGE' => 'Cash transactions cannot exceed $500.']); + } + + // Check 30-day rolling limit for customer + if ($customerUserID > 0) { + $q30Day = queryOne(" + SELECT COALESCE(SUM(PaymentPaidInCash), 0) AS TotalCashDollars + FROM Payments + WHERE PaymentSentByUserID = ? + AND PaymentAddedOn >= DATE_SUB(NOW(), INTERVAL 30 DAY) + ", [$customerUserID]); + if (((float) $q30Day['TotalCashDollars'] + ($cashReceivedCents / 100)) > 10000) { + apiAbort(['OK' => false, 'ERROR' => 'cash_30day_limit', 'MESSAGE' => 'Customer has exceeded the $10,000 rolling 30-day cash limit.']); + } + } + + if ((int) $qTask['OrderID'] <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'no_order', 'MESSAGE' => 'Cash tasks must be linked to an order.']); + } + + // Calculate order total from line items + $qOrderTotal = queryOne(" + SELECT SUM(oli.Price * oli.Quantity) AS Subtotal, o.TipAmount, o.DeliveryFee, o.OrderTypeID, + o.BalanceApplied, b.TaxRate, b.PayfritFee + FROM Orders o + INNER JOIN Businesses b ON b.ID = o.BusinessID + LEFT JOIN OrderLineItems oli ON oli.OrderID = o.ID AND oli.IsDeleted = 0 + WHERE o.ID = ? + GROUP BY o.ID + ", [$qTask['OrderID']]); + + if (!$qOrderTotal) { + apiAbort(['OK' => false, 'ERROR' => 'no_order', 'MESSAGE' => 'Order not found for this cash task.']); + } + + $cashSubtotal = (float) $qOrderTotal['Subtotal']; + $cashTax = $cashSubtotal * (float) $qOrderTotal['TaxRate']; + $cashTip = (float) $qOrderTotal['TipAmount']; + $cashDeliveryFee = ((int) $qOrderTotal['OrderTypeID'] === 3) ? (float) $qOrderTotal['DeliveryFee'] : 0; + $cashPayfritFee = (is_numeric($qOrderTotal['PayfritFee']) && (float) $qOrderTotal['PayfritFee'] > 0) + ? (float) $qOrderTotal['PayfritFee'] : 0.05; + + $customerFeeDollars = $cashSubtotal * $cashPayfritFee; + $businessFeeDollars = $cashSubtotal * $cashPayfritFee; + $payfritRevenueDollars = $customerFeeDollars + $businessFeeDollars; + + $orderTotalCents = (int) round(($cashSubtotal + $cashTax + $cashTip + $cashDeliveryFee + $customerFeeDollars) * 100); + + $balanceAppliedCents = (int) round((float) ($qOrderTotal['BalanceApplied'] ?? 0) * 100); + $cashOwedCents = $orderTotalCents - $balanceAppliedCents; + if ($cashOwedCents < 0) $cashOwedCents = 0; + + if ($cashReceivedCents < $cashOwedCents) { + apiAbort(['OK' => false, 'ERROR' => 'insufficient_cash', 'MESSAGE' => sprintf( + 'Cash received ($%s) is less than cash owed ($%s).', + number_format($cashReceivedCents / 100, 2), + number_format($cashOwedCents / 100, 2) + )]); + } + + $payfritRevenueCents = (int) round($payfritRevenueDollars * 100); + $changeCents = $cashReceivedCents - $cashOwedCents; + $businessReceivesCents = $orderTotalCents - $payfritRevenueCents; + } + + // Mark task as completed + queryTimed("UPDATE Tasks SET CompletedOn = NOW() WHERE ID = ?", [$taskID]); + + // Update order status based on task type + $orderUpdated = false; + if ((int) $qTask['OrderID'] > 0) { + if ($cancelOrder) { + // Cancel task only: leave order untouched + $orderCancelled = false; + } elseif ($isCashTask) { + queryTimed(" + UPDATE Orders + SET StatusID = CASE WHEN StatusID = 0 THEN 1 ELSE StatusID END, + PaymentStatus = 'paid', + PaymentCompletedOn = NOW(), + SubmittedOn = CASE WHEN SubmittedOn IS NULL THEN NOW() ELSE SubmittedOn END, + LastEditedOn = NOW() + WHERE ID = ? + ", [$qTask['OrderID']]); + } else { + queryTimed(" + UPDATE Orders + SET StatusID = 5, LastEditedOn = NOW() + WHERE ID = ? + ", [$qTask['OrderID']]); + } + $orderUpdated = true; + } + + // === PAYOUT LEDGER + ACTIVATION WITHHOLDING === + $ledgerCreated = false; + $workerUserID_for_payout = (int) $qTask['ClaimedByUserID']; + if ($workerUserID_for_payout > 0 && !$isAdminRole) { + $qTaskPay = queryOne("SELECT PayCents FROM Tasks WHERE ID = ?", [$taskID]); + $grossCents = (int) ($qTaskPay['PayCents'] ?? 0); + + if ($grossCents > 0) { + $qActivation = queryOne(" + SELECT ActivationBalanceCents, ActivationCapCents + FROM Users WHERE ID = ? + ", [$workerUserID_for_payout]); + + $activationBalance = (int) ($qActivation['ActivationBalanceCents'] ?? 0); + $activationCap = (int) ($qActivation['ActivationCapCents'] ?? 0); + $remainingActivation = $activationCap - $activationBalance; + + $activationWithhold = 0; + if ($remainingActivation > 0) { + $activationWithhold = min(100, min($remainingActivation, $grossCents)); + } + $netCents = $grossCents - $activationWithhold; + + queryTimed(" + INSERT INTO WorkPayoutLedgers + (UserID, TaskID, GrossEarningsCents, ActivationWithheldCents, NetTransferCents, Status) + VALUES (?, ?, ?, ?, ?, 'pending_charge') + ", [$workerUserID_for_payout, $taskID, $grossCents, $activationWithhold, $netCents]); + + if ($activationWithhold > 0) { + queryTimed(" + UPDATE Users + SET ActivationBalanceCents = ActivationBalanceCents + ? + WHERE ID = ? + ", [$activationWithhold, $workerUserID_for_payout]); + } + + $ledgerCreated = true; + } + } + + // === CASH TRANSACTION PROCESSING === + $cashProcessed = false; + if ($isCashTask && $cashReceivedCents > 0 && (int) $qTask['OrderID'] > 0 && !$cancelOrder) { + // Look up worker's role for this business + $workerRoleID = 1; + if ($workerUserID_for_payout > 0) { + $qWorkerRole = queryOne(" + SELECT COALESCE(RoleID, 1) AS RoleID + FROM Employees + WHERE UserID = ? AND BusinessID = ? AND IsActive = 1 + ORDER BY RoleID DESC + LIMIT 1 + ", [$workerUserID_for_payout, $qTask['BusinessID']]); + if ($qWorkerRole) { + $workerRoleID = (int) $qWorkerRole['RoleID']; + } + } + + $isAdmin = ($workerRoleID >= 2); + + // Credit customer change to their balance + if ($changeCents > 0 && $customerUserID > 0) { + queryTimed("UPDATE Users SET Balance = Balance + ? WHERE ID = ?", [$changeCents / 100, $customerUserID]); + } + + // Credit Payfrit revenue to User 0 + if ($payfritRevenueCents > 0) { + queryTimed("UPDATE Users SET Balance = Balance + ? WHERE ID = 0", [$payfritRevenueCents / 100]); + } + + // Debit for physical cash received + if ($isAdmin) { + // Admin/Manager: cash goes to the business, delete worker's pending payout + if ($workerUserID_for_payout > 0) { + queryTimed(" + DELETE FROM WorkPayoutLedgers + WHERE TaskID = ? AND UserID = ? AND Status = 'pending_charge' + ", [$taskID, $workerUserID_for_payout]); + + if ($ledgerCreated && $activationWithhold > 0) { + queryTimed(" + UPDATE Users + SET ActivationBalanceCents = GREATEST(0, ActivationBalanceCents - ?) + WHERE ID = ? + ", [$activationWithhold, $workerUserID_for_payout]); + } + } + } else { + // Staff: cash stays in their pocket, debit their payout balance + if ($workerUserID_for_payout > 0) { + queryTimed(" + INSERT INTO WorkPayoutLedgers + (UserID, TaskID, GrossEarningsCents, ActivationWithheldCents, NetTransferCents, Status) + VALUES (?, ?, ?, 0, ?, 'cash_debit') + ", [$workerUserID_for_payout, $taskID, -$cashReceivedCents, -$cashReceivedCents]); + } + } + + // Log transaction in Payments table + queryTimed(" + INSERT INTO Payments ( + PaymentSentByUserID, PaymentReceivedByUserID, PaymentOrderID, + PaymentFromCreditCard, PaymentFromPayfritBalance, PaymentPaidInCash, + PaymentPayfritsCut, PaymentCreditCardFees, PaymentPayfritNetworkFees, + PaymentRemark, PaymentAddedOn + ) VALUES (?, ?, ?, 0, 0, ?, ?, 0, ?, ?, NOW()) + ", [ + $customerUserID, + $businessOwnerUserID, + $qTask['OrderID'], + $cashReceivedCents / 100, + $payfritRevenueCents / 100, + round($businessFeeDollars * 100) / 100, + $isAdmin ? 'Cash payment (collected by manager)' : 'Cash payment', + ]); + + $cashProcessed = true; + } + + // Create rating records for service point tasks + if ($hasServicePoint && $customerUserID > 0 && $workerUserID > 0) { + // 1. Customer rates Worker + $customerToken = strtolower(str_replace('-', '', generateUUID())); + queryTimed(" + INSERT INTO TaskRatings ( + TaskID, ByUserID, ForUserID, Direction, + AccessToken, ExpiresOn + ) VALUES (?, ?, ?, 'customer_rates_worker', ?, DATE_ADD(NOW(), INTERVAL 24 HOUR)) + ", [$taskID, $customerUserID, $workerUserID, $customerToken]); + $ratingsCreated[] = ['direction' => 'customer_rates_worker', 'token' => $customerToken]; + + // 2. Worker rates Customer (if provided) + if (!empty($workerRating)) { + $workerToken = strtolower(str_replace('-', '', generateUUID())); + queryTimed(" + INSERT INTO TaskRatings ( + TaskID, ByUserID, ForUserID, Direction, + Prepared, CompletedScope, Respectful, WouldAutoAssign, + AccessToken, ExpiresOn, CompletedOn + ) VALUES (?, ?, ?, 'worker_rates_customer', ?, ?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 24 HOUR), NOW()) + ", [ + $taskID, + $workerUserID, + $customerUserID, + isset($workerRating['prepared']) ? ($workerRating['prepared'] ? 1 : 0) : null, + isset($workerRating['completedScope']) ? ($workerRating['completedScope'] ? 1 : 0) : null, + isset($workerRating['respectful']) ? ($workerRating['respectful'] ? 1 : 0) : null, + isset($workerRating['wouldAutoAssign']) ? ($workerRating['wouldAutoAssign'] ? 1 : 0) : null, + $workerToken, + ]); + $ratingsCreated[] = ['direction' => 'worker_rates_customer', 'submitted' => true]; + } + } + + $response = [ + 'OK' => true, + 'ERROR' => '', + 'MESSAGE' => $orderCancelled ? 'Order cancelled.' : 'Task completed successfully.', + 'TaskID' => $taskID, + 'OrderUpdated' => $orderUpdated, + 'OrderCancelled' => $orderCancelled, + 'RatingsCreated' => $ratingsCreated, + 'LedgerCreated' => $ledgerCreated, + 'CashProcessed' => $cashProcessed, + ]; + + if ($cashProcessed) { + $response['CashReceived'] = number_format($cashReceivedCents / 100, 2, '.', ''); + $response['OrderTotal'] = number_format($orderTotalCents / 100, 2, '.', ''); + $response['Change'] = number_format($changeCents / 100, 2, '.', ''); + $response['CustomerFee'] = number_format(round($customerFeeDollars * 100) / 100, 2, '.', ''); + $response['BusinessFee'] = number_format(round($businessFeeDollars * 100) / 100, 2, '.', ''); + $response['PayfritRevenue'] = number_format($payfritRevenueCents / 100, 2, '.', ''); + $response['BusinessReceives'] = number_format($businessReceivesCents / 100, 2, '.', ''); + if ($balanceAppliedCents > 0) { + $response['BalanceApplied'] = number_format($balanceAppliedCents / 100, 2, '.', ''); + $response['CashOwed'] = number_format($cashOwedCents / 100, 2, '.', ''); + } + $response['CashRoutedTo'] = $isAdmin ? 'business' : 'worker'; + $response['WorkerRoleID'] = $workerRoleID; + } + + jsonResponse($response); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => 'Error completing task', 'DETAIL' => $e->getMessage()]); +} diff --git a/api/tasks/completeChat.php b/api/tasks/completeChat.php new file mode 100644 index 0000000..7b33104 --- /dev/null +++ b/api/tasks/completeChat.php @@ -0,0 +1,46 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'TaskID is required.']); +} + +try { + $qTask = queryOne(" + SELECT ID, ClaimedByUserID, CompletedOn, OrderID, TaskTypeID + FROM Tasks WHERE ID = ? + ", [$taskID]); + + if (!$qTask) { + apiAbort(['OK' => false, 'ERROR' => 'not_found', 'MESSAGE' => 'Task not found.']); + } + + if ((int) $qTask['TaskTypeID'] !== 2) { + apiAbort(['OK' => false, 'ERROR' => 'not_chat', 'MESSAGE' => 'This endpoint is only for chat tasks.']); + } + + if (!empty(trim($qTask['CompletedOn'] ?? ''))) { + apiAbort(['OK' => false, 'ERROR' => 'already_completed', 'MESSAGE' => 'Chat has already been closed.']); + } + + queryTimed("UPDATE Tasks SET CompletedOn = NOW() WHERE ID = ?", [$taskID]); + + jsonResponse([ + 'OK' => true, + 'ERROR' => '', + 'MESSAGE' => 'Chat closed successfully.', + 'TaskID' => $taskID, + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => 'Error closing chat', 'DETAIL' => $e->getMessage()]); +} diff --git a/api/tasks/create.php b/api/tasks/create.php new file mode 100644 index 0000000..8ed7ea1 --- /dev/null +++ b/api/tasks/create.php @@ -0,0 +1,95 @@ + false, 'ERROR' => 'BusinessID is required']); +} + +try { + $taskTypeID = (int) ($data['TaskTypeID'] ?? 0); + + if ($taskTypeID > 0) { + // Service bell task + $servicePointID = (int) ($data['ServicePointID'] ?? 0); + $orderID = (int) ($data['OrderID'] ?? 0); + $userID = (int) ($data['UserID'] ?? 0); + $message = $data['Message'] ?? ''; + + // Get task type name for display + $taskTypeQuery = queryOne("SELECT Name FROM tt_TaskTypes WHERE ID = ?", [$taskTypeID]); + + $taskTitle = $message; + if ($taskTypeQuery && !empty(trim($taskTypeQuery['Name']))) { + $taskTitle = $taskTypeQuery['Name']; + } + + $taskDetails = $message; + + queryTimed(" + INSERT INTO Tasks ( + BusinessID, ServicePointID, TaskTypeID, OrderID, UserID, + Title, Details, CreatedOn, ClaimedByUserID + ) VALUES (?, ?, ?, ?, ?, ?, ?, NOW(), 0) + ", [ + $businessID, + $servicePointID, + $taskTypeID, + $orderID, + $userID, + $taskTitle, + $taskDetails, + ]); + } else { + // Legacy photo task + $itemID = (int) ($data['ItemID'] ?? 0); + $taskType = $data['TaskType'] ?? 'employee_photo'; + $instructions = $data['Instructions'] ?? ''; + $pytReward = (int) ($data['PYTReward'] ?? 0); + + // Get item info if itemID provided + $itemName = ''; + if ($itemID > 0) { + $itemQuery = queryOne("SELECT Name FROM Items WHERE ID = ?", [$itemID]); + if ($itemQuery) $itemName = $itemQuery['Name']; + } + + // Create task description + switch ($taskType) { + case 'employee_photo': + $taskDescription = "Take a photo of: $itemName"; + break; + case 'user_photo': + $taskDescription = "Submit a photo of $itemName to earn $pytReward PYT"; + break; + default: + $taskDescription = $instructions; + } + + queryTimed(" + INSERT INTO Tasks ( + BusinessID, TaskItemID, TaskType, TaskDescription, + TaskInstructions, TaskPYTReward, TaskStatus, CreatedOn + ) VALUES (?, ?, ?, ?, ?, ?, 'pending', NOW()) + ", [$businessID, $itemID, $taskType, $taskDescription, $instructions, $pytReward]); + } + + $taskID = lastInsertId(); + + jsonResponse([ + 'OK' => true, + 'TASK_ID' => (int) $taskID, + 'MESSAGE' => 'Task created successfully', + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => $e->getMessage(), 'DETAIL' => '']); +} diff --git a/api/tasks/createChat.php b/api/tasks/createChat.php new file mode 100644 index 0000000..7dce96a --- /dev/null +++ b/api/tasks/createChat.php @@ -0,0 +1,172 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'BusinessID is required']); +} + +try { + // If servicePointID not provided but orderID is, look it up + if ($servicePointID <= 0 && $orderID > 0) { + $qOrderSP = queryOne("SELECT ServicePointID FROM Orders WHERE ID = ?", [$orderID]); + if ($qOrderSP && (int) $qOrderSP['ServicePointID'] > 0) { + $servicePointID = (int) $qOrderSP['ServicePointID']; + } + } + + // Look up "Chat" task type for this business + $ttQuery = queryOne(" + SELECT ID FROM tt_TaskTypes + WHERE BusinessID = ? AND Name LIKE '%Chat%' + LIMIT 1 + ", [$businessID]); + $chatTaskTypeID = $ttQuery ? (int) $ttQuery['ID'] : 0; + + // Check for existing open chat + $forceNew = !empty($data['ForceNew']) && $data['ForceNew'] === true; + + if (!$forceNew) { + $existingChat = queryOne(" + SELECT t.ID, t.CreatedOn, + (SELECT MAX(cm.CreatedOn) FROM ChatMessages cm WHERE cm.TaskID = t.ID) as LastMessageTime + FROM Tasks t + LEFT JOIN ChatMessages cm2 ON cm2.TaskID = t.ID AND cm2.SenderUserID = ? + WHERE t.BusinessID = ? + AND (t.TaskTypeID = ? OR t.Title LIKE 'Chat%') + AND t.CompletedOn IS NULL + AND ( + (t.OrderID = ? AND ? > 0) + OR (t.SourceType = 'servicepoint' AND t.SourceID = ? AND ? > 0) + OR (t.SourceType = 'user' AND t.SourceID = ? AND ? > 0) + OR (cm2.SenderUserID = ? AND ? > 0) + ) + ORDER BY t.CreatedOn DESC + LIMIT 1 + ", [ + $userID, $businessID, $chatTaskTypeID, + $orderID, $orderID, + $servicePointID, $servicePointID, + $userID, $userID, + $userID, $userID, + ]); + + if ($existingChat) { + $lastActivity = $existingChat['LastMessageTime']; + if (empty($lastActivity)) { + $lastActivity = $existingChat['CreatedOn']; + } + $chatAge = (int) ((time() - strtotime($lastActivity)) / 60); + + if ($chatAge > 30) { + // Auto-close stale chat + queryTimed("UPDATE Tasks SET CompletedOn = NOW() WHERE ID = ?", [$existingChat['ID']]); + } else { + jsonResponse([ + 'OK' => true, + 'TaskID' => (int) $existingChat['ID'], + 'MESSAGE' => 'Rejoined existing chat', + 'EXISTING' => true, + ]); + } + } + } + + // Get service point info + $tableName = ''; + if ($servicePointID > 0) { + $spQuery = queryOne("SELECT Name FROM ServicePoints WHERE ID = ?", [$servicePointID]); + $tableName = $spQuery ? $spQuery['Name'] : "Table #$servicePointID"; + } + + // Get user name + $userName = ''; + if ($userID > 0) { + $userQuery = queryOne("SELECT FirstName FROM Users WHERE ID = ?", [$userID]); + if ($userQuery && !empty(trim($userQuery['FirstName']))) { + $userName = $userQuery['FirstName']; + } + } + + // Create task title + if ($servicePointID > 0) { + $taskTitle = !empty($userName) + ? "Chat - $userName ($tableName)" + : "Chat - $tableName"; + } else { + $taskTitle = !empty($userName) + ? "Chat - $userName (Remote)" + : "Remote Chat"; + } + + $taskDetails = !empty($initialMessage) ? $initialMessage : 'Customer initiated chat'; + + // Look up or create a "Chat" category + $catQuery = queryOne(" + SELECT ID FROM TaskCategories + WHERE BusinessID = ? AND Name = 'Chat' + LIMIT 1 + ", [$businessID]); + + if (!$catQuery) { + queryTimed(" + INSERT INTO TaskCategories (BusinessID, Name, Color) + VALUES (?, 'Chat', '#2196F3') + ", [$businessID]); + $categoryID = (int) lastInsertId(); + } else { + $categoryID = (int) $catQuery['ID']; + } + + // Determine source type and ID + $sourceType = $servicePointID > 0 ? 'servicepoint' : 'user'; + $sourceID = $servicePointID > 0 ? $servicePointID : $userID; + + // Insert task + queryTimed(" + INSERT INTO Tasks ( + BusinessID, CategoryID, OrderID, TaskTypeID, + Title, Details, ClaimedByUserID, SourceType, SourceID, CreatedOn + ) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, NOW()) + ", [ + $businessID, + $categoryID, + $orderID > 0 ? $orderID : null, + $chatTaskTypeID > 0 ? $chatTaskTypeID : null, + $taskTitle, + $taskDetails, + $sourceType, + $sourceID, + ]); + + $taskID = (int) lastInsertId(); + + // If there's an initial message, save it + if (!empty($initialMessage) && $userID > 0) { + queryTimed(" + INSERT INTO ChatMessages (TaskID, SenderUserID, SenderType, MessageBody) + VALUES (?, ?, 'customer', ?) + ", [$taskID, $userID, $initialMessage]); + } + + jsonResponse([ + 'OK' => true, + 'TaskID' => $taskID, + 'MESSAGE' => 'Chat started', + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/tasks/deleteCategory.php b/api/tasks/deleteCategory.php new file mode 100644 index 0000000..53e9cf0 --- /dev/null +++ b/api/tasks/deleteCategory.php @@ -0,0 +1,52 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'BusinessID is required']); +} + +$categoryID = (int) ($data['TaskCategoryID'] ?? 0); +if ($categoryID <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'missing_params', 'MESSAGE' => 'TaskCategoryID is required']); +} + +try { + // Verify ownership + $qCheck = queryOne(" + SELECT ID FROM TaskCategories WHERE ID = ? AND BusinessID = ? + ", [$categoryID, $bizID]); + + if (!$qCheck) { + apiAbort(['OK' => false, 'ERROR' => 'not_found', 'MESSAGE' => 'Category not found']); + } + + // Check if any tasks use this category + $qTasks = queryOne("SELECT COUNT(*) as cnt FROM Tasks WHERE CategoryID = ?", [$categoryID]); + + if ((int) $qTasks['cnt'] > 0) { + // Soft delete + queryTimed("UPDATE TaskCategories SET IsActive = 0 WHERE ID = ?", [$categoryID]); + jsonResponse(['OK' => true, 'MESSAGE' => "Category deactivated (has {$qTasks['cnt']} tasks)"]); + } else { + // Hard delete + queryTimed("DELETE FROM TaskCategories WHERE ID = ?", [$categoryID]); + jsonResponse(['OK' => true, 'MESSAGE' => 'Category deleted']); + } + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/tasks/deleteType.php b/api/tasks/deleteType.php new file mode 100644 index 0000000..03487e1 --- /dev/null +++ b/api/tasks/deleteType.php @@ -0,0 +1,38 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'TaskTypeID is required']); +} +if ($businessID <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'missing_params', 'MESSAGE' => 'BusinessID is required']); +} + +try { + $qCheck = queryOne("SELECT ID, BusinessID FROM tt_TaskTypes WHERE ID = ?", [$taskTypeID]); + + if (!$qCheck) { + apiAbort(['OK' => false, 'ERROR' => 'not_found', 'MESSAGE' => 'Task type not found']); + } + + if ((int) ($qCheck['BusinessID'] ?? 0) !== $businessID) { + apiAbort(['OK' => false, 'ERROR' => 'not_authorized', 'MESSAGE' => 'Task type does not belong to this business']); + } + + queryTimed("DELETE FROM tt_TaskTypes WHERE ID = ? AND BusinessID = ?", [$taskTypeID, $businessID]); + + jsonResponse(['OK' => true, 'MESSAGE' => 'Task type deleted']); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/tasks/expireStaleChats.php b/api/tasks/expireStaleChats.php new file mode 100644 index 0000000..178c798 --- /dev/null +++ b/api/tasks/expireStaleChats.php @@ -0,0 +1,51 @@ +20 min with no activity) + */ + +try { + // Find open chat tasks older than 20 minutes + $staleChats = queryTimed(" + SELECT t.ID, t.CreatedOn, + (SELECT MAX(cm.CreatedOn) FROM ChatMessages cm WHERE cm.TaskID = t.ID) as LastMessageOn + FROM Tasks t + WHERE t.TaskTypeID = 2 + AND t.CompletedOn IS NULL + AND t.CreatedOn < DATE_SUB(NOW(), INTERVAL 20 MINUTE) + ", []); + + $expiredCount = 0; + $expiredIds = []; + + foreach ($staleChats as $chat) { + $shouldExpire = false; + + if (empty($chat['LastMessageOn'])) { + $shouldExpire = true; + } else { + $lastMsgAge = (int) ((time() - strtotime($chat['LastMessageOn'])) / 60); + if ($lastMsgAge > 20) { + $shouldExpire = true; + } + } + + if ($shouldExpire) { + queryTimed("UPDATE Tasks SET CompletedOn = NOW() WHERE ID = ?", [$chat['ID']]); + $expiredCount++; + $expiredIds[] = (int) $chat['ID']; + } + } + + jsonResponse([ + 'OK' => true, + 'MESSAGE' => "Expired $expiredCount stale chat(s)", + 'EXPIRED_TASK_IDS' => $expiredIds, + 'CHECKED_COUNT' => count($staleChats), + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/tasks/getDetails.php b/api/tasks/getDetails.php new file mode 100644 index 0000000..5763496 --- /dev/null +++ b/api/tasks/getDetails.php @@ -0,0 +1,183 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'TaskID is required.']); +} + +try { + $qTask = queryOne(" + SELECT + t.ID AS TaskID, + t.BusinessID, + t.OrderID, + t.TaskTypeID, + t.CreatedOn, + t.ClaimedByUserID, + t.ServicePointID AS TaskServicePointID, + tt.Name AS TaskTypeName, + tt.Color AS TaskTypeColor, + o.ID AS OID, + o.UUID AS OrderUUID, + o.UserID AS OrderUserID, + o.OrderTypeID, + o.StatusID AS OrderStatusID, + o.ServicePointID AS OrderServicePointID, + o.Remarks, + o.SubmittedOn, + o.TipAmount, + o.DeliveryFee, + b.TaxRate, + b.PayfritFee, + COALESCE(sp.Name, tsp.Name) AS ServicePointName, + COALESCE(sp.TypeID, tsp.TypeID) AS ServicePointTypeID, + COALESCE(sp.ID, tsp.ID) AS ServicePointID, + u.ID AS CustomerUserID, + u.FirstName, + u.LastName, + u.ContactNumber, + u.ImageExtension AS CustomerImageExtension + FROM Tasks t + LEFT JOIN tt_TaskTypes tt ON tt.ID = t.TaskTypeID + LEFT JOIN Orders o ON o.ID = t.OrderID + LEFT JOIN Businesses b ON b.ID = t.BusinessID + LEFT JOIN ServicePoints sp ON sp.ID = o.ServicePointID + LEFT JOIN ServicePoints tsp ON tsp.ID = t.ServicePointID + LEFT JOIN Users u ON u.ID = COALESCE(NULLIF(o.UserID, 0), NULLIF(t.UserID, 0)) + WHERE t.ID = ? + ", [$taskID]); + + if (!$qTask) { + apiAbort(['OK' => false, 'ERROR' => 'not_found', 'MESSAGE' => 'Task not found.']); + } + + $taskTitle = ((int) ($qTask['OrderID'] ?? 0) > 0) + ? "Order #" . $qTask['OrderID'] + : "Task #" . $qTask['TaskID']; + + // Check if user photo file exists + $customerPhotoUrl = ''; + $customerUserID = (int) ($qTask['CustomerUserID'] ?? 0); + if ($customerUserID > 0) { + $baseDir = '/uploads/users/'; + foreach (['jpg', 'png', 'PNG'] as $ext) { + $checkPath = $_SERVER['DOCUMENT_ROOT'] . $baseDir . $customerUserID . '.' . $ext; + if (file_exists($checkPath)) { + $customerPhotoUrl = baseUrl() . $baseDir . $customerUserID . '.' . $ext; + break; + } + } + } + + $result = [ + 'TaskID' => (int) $qTask['TaskID'], + 'TaskBusinessID' => (int) $qTask['BusinessID'], + 'TaskTypeID' => (int) ($qTask['TaskTypeID'] ?? 1), + 'TaskTypeName' => $qTask['TaskTypeName'] ?? '', + 'TaskTypeColor' => !empty(trim($qTask['TaskTypeColor'] ?? '')) ? $qTask['TaskTypeColor'] : '#9C27B0', + 'TaskTitle' => $taskTitle, + 'TaskCreatedOn' => toISO8601($qTask['CreatedOn']), + 'TaskStatusID' => (int) $qTask['ClaimedByUserID'] > 0 ? 1 : 0, + 'OrderID' => (int) ($qTask['OrderID'] ?? 0), + 'OrderRemarks' => $qTask['Remarks'] ?? '', + 'OrderSubmittedOn' => toISO8601($qTask['SubmittedOn'] ?? ''), + 'OrderTotal' => 0, + 'OrderTotalCents' => 0, + 'ServicePointID' => (int) ($qTask['ServicePointID'] ?? 0), + 'ServicePointName' => $qTask['ServicePointName'] ?? '', + 'ServicePointTypeID' => (int) ($qTask['ServicePointTypeID'] ?? 0), + 'DeliveryAddress' => '', + 'DeliveryLat' => 0, + 'DeliveryLng' => 0, + 'CustomerUserID' => $customerUserID, + 'CustomerFirstName' => $qTask['FirstName'] ?? '', + 'CustomerLastName' => $qTask['LastName'] ?? '', + 'CustomerPhone' => $qTask['ContactNumber'] ?? '', + 'CustomerPhotoUrl' => $customerPhotoUrl, + 'BeaconUUID' => '', + 'BeaconMajor' => 0, + 'BeaconMinor' => 0, + 'LineItems' => [], + 'TableMembers' => [], + ]; + + // Get beacon sharding info + $spID = (int) ($qTask['ServicePointID'] ?? 0); + if ($spID > 0) { + $qBeacon = queryOne(" + SELECT bs.UUID AS ShardUUID, b.BeaconMajor, sp.BeaconMinor + FROM ServicePoints sp + JOIN Businesses b ON b.ID = sp.BusinessID + JOIN BeaconShards bs ON bs.ID = b.BeaconShardID + WHERE sp.ID = ? AND bs.IsActive = 1 + LIMIT 1 + ", [$spID]); + if ($qBeacon) { + $result['BeaconUUID'] = $qBeacon['ShardUUID']; + $result['BeaconMajor'] = (int) $qBeacon['BeaconMajor']; + $result['BeaconMinor'] = (int) $qBeacon['BeaconMinor']; + } + } + + // Get order line items if there's an order + if ((int) ($qTask['OrderID'] ?? 0) > 0) { + $qLineItems = queryTimed(" + SELECT + oli.ID AS OrderLineItemID, + oli.ParentOrderLineItemID, + oli.ItemID, + oli.Price AS LineItemPrice, + oli.Quantity, + oli.Remark, + i.ID AS IID, + i.Name AS ItemName, + i.ParentItemID, + i.Price AS ItemPrice, + i.IsCheckedByDefault + FROM OrderLineItems oli + INNER JOIN Items i ON i.ID = oli.ItemID + WHERE oli.OrderID = ? AND oli.IsDeleted = 0 + ORDER BY oli.ID + ", [$qTask['OrderID']]); + + $subtotal = 0; + foreach ($qLineItems as $li) { + $subtotal += (float) $li['LineItemPrice'] * (int) $li['Quantity']; + $result['LineItems'][] = [ + 'LineItemID' => (int) $li['OrderLineItemID'], + 'ParentLineItemID' => (int) $li['ParentOrderLineItemID'], + 'ItemID' => (int) $li['ItemID'], + 'ItemName' => $li['ItemName'], + 'ItemPrice' => (float) $li['LineItemPrice'], + 'Quantity' => (int) $li['Quantity'], + 'Remark' => $li['Remark'] ?? '', + 'IsModifier' => (int) $li['ParentOrderLineItemID'] > 0, + ]; + } + + // Calculate order total + $taxAmount = $subtotal * (float) ($qTask['TaxRate'] ?? 0); + $tipAmount = (float) ($qTask['TipAmount'] ?? 0); + $deliveryFee = ((int) ($qTask['OrderTypeID'] ?? 0) === 3) ? (float) ($qTask['DeliveryFee'] ?? 0) : 0; + $feeRate = (is_numeric($qTask['PayfritFee'] ?? null) && (float) $qTask['PayfritFee'] > 0) + ? (float) $qTask['PayfritFee'] : 0.05; + $platformFee = $subtotal * $feeRate; + $totalAmount = $subtotal + $taxAmount + $tipAmount + $deliveryFee + $platformFee; + $result['OrderTotal'] = number_format($totalAmount, 2, '.', ''); + $result['OrderTotalCents'] = (int) round($totalAmount * 100); + } + + jsonResponse(['OK' => true, 'ERROR' => '', 'TASK' => $result]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => 'Error loading task details', 'DETAIL' => $e->getMessage()]); +} diff --git a/api/tasks/listAllTypes.php b/api/tasks/listAllTypes.php new file mode 100644 index 0000000..cebc3e4 --- /dev/null +++ b/api/tasks/listAllTypes.php @@ -0,0 +1,58 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'BusinessID is required']); +} + +try { + $q = queryTimed(" + SELECT + ID as TaskTypeID, + Name as TaskTypeName, + Description as TaskTypeDescription, + Icon as TaskTypeIcon, + Color as TaskTypeColor, + RequiresServicePoint, + SortOrder, + TaskCategoryID as CategoryID + FROM tt_TaskTypes + WHERE BusinessID = ? + ORDER BY SortOrder, ID + ", [$bizID]); + + $taskTypes = []; + foreach ($q as $row) { + $taskTypes[] = [ + 'TaskTypeID' => (int) $row['TaskTypeID'], + 'TaskTypeName' => $row['TaskTypeName'], + 'TaskTypeDescription' => $row['TaskTypeDescription'] ?? '', + 'TaskTypeIcon' => $row['TaskTypeIcon'] ?? 'notifications', + 'TaskTypeColor' => $row['TaskTypeColor'] ?? '#9C27B0', + 'CategoryID' => $row['CategoryID'] ?? '', + 'RequiresServicePoint' => ((int) ($row['RequiresServicePoint'] ?? 0)) === 1, + ]; + } + + jsonResponse([ + 'OK' => true, + 'TASK_TYPES' => $taskTypes, + 'COUNT' => count($taskTypes), + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/tasks/listCategories.php b/api/tasks/listCategories.php new file mode 100644 index 0000000..4a54653 --- /dev/null +++ b/api/tasks/listCategories.php @@ -0,0 +1,44 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'BusinessID is required']); +} + +try { + $q = queryTimed(" + SELECT ID, Name, Color + FROM TaskCategories + WHERE BusinessID = ? AND IsActive = 1 + ORDER BY Name + ", [$bizID]); + + $categories = []; + foreach ($q as $row) { + $categories[] = [ + 'TaskCategoryID' => (int) $row['ID'], + 'Name' => $row['Name'], + 'Color' => $row['Color'], + ]; + } + + jsonResponse(['OK' => true, 'CATEGORIES' => $categories]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/tasks/listMine.php b/api/tasks/listMine.php new file mode 100644 index 0000000..b9bc76a --- /dev/null +++ b/api/tasks/listMine.php @@ -0,0 +1,134 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'UserID is required.']); +} + +try { + $where = "t.ClaimedByUserID = ?"; + $params = [$userID]; + + if ($businessID > 0) { + $where .= " AND t.BusinessID = ?"; + $params[] = $businessID; + } + + switch ($filterType) { + case 'active': + $where .= " AND t.CompletedOn IS NULL"; + break; + case 'completed': + $where .= " AND t.CompletedOn IS NOT NULL"; + break; + case 'today': + $where .= " AND DATE(t.ClaimedOn) = CURDATE()"; + break; + case 'week': + $where .= " AND t.ClaimedOn >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)"; + break; + } + + $qTasks = queryTimed(" + SELECT + t.ID, t.BusinessID, t.OrderID, t.TaskTypeID, + t.Title, t.Details, t.CreatedOn, t.ClaimedByUserID, + t.ClaimedOn, t.CompletedOn, t.UserID, + tt.Name AS TaskTypeName, tt.Icon AS TaskTypeIcon, tt.Color AS TaskTypeColor, + b.Name AS BusinessName, + t.ServicePointID, + sp.Name AS ServicePointName, + u.FirstName AS CustomerFirstName, + u.LastName AS CustomerLastName + FROM Tasks t + LEFT JOIN tt_TaskTypes tt ON tt.ID = t.TaskTypeID + LEFT JOIN Businesses b ON b.ID = t.BusinessID + LEFT JOIN Orders o ON o.ID = t.OrderID + LEFT JOIN ServicePoints sp ON sp.ID = COALESCE(t.ServicePointID, o.ServicePointID) + LEFT JOIN Users u ON u.ID = t.UserID + WHERE $where + ORDER BY t.ClaimedOn DESC + ", $params); + + $tasks = []; + foreach ($qTasks as $row) { + $taskTitle = !empty(trim($row['Title'] ?? '')) + ? $row['Title'] + : ((int) $row['OrderID'] > 0 ? "Order #" . $row['OrderID'] : "Task #" . $row['ID']); + + $customerName = ''; + if (!empty(trim($row['CustomerFirstName'] ?? ''))) { + $customerName = $row['CustomerFirstName']; + if (!empty(trim($row['CustomerLastName'] ?? ''))) { + $customerName .= ' ' . $row['CustomerLastName']; + } + } + + $tasks[] = [ + 'TaskID' => (int) $row['ID'], + 'BusinessID' => (int) $row['BusinessID'], + 'BusinessName' => $row['BusinessName'] ?? '', + 'TaskTypeID' => (int) $row['TaskTypeID'], + 'Title' => $taskTitle, + 'Details' => !empty(trim($row['Details'] ?? '')) ? $row['Details'] : '', + 'CreatedOn' => toISO8601($row['CreatedOn']), + 'ClaimedOn' => toISO8601($row['ClaimedOn'] ?? ''), + 'CompletedOn' => toISO8601($row['CompletedOn'] ?? ''), + 'StatusID' => empty(trim($row['CompletedOn'] ?? '')) ? 1 : 3, + 'SourceType' => 'order', + 'SourceID' => (int) $row['OrderID'], + 'TaskTypeName' => !empty(trim($row['TaskTypeName'] ?? '')) ? $row['TaskTypeName'] : '', + 'TaskTypeIcon' => !empty(trim($row['TaskTypeIcon'] ?? '')) ? $row['TaskTypeIcon'] : 'notifications', + 'TaskTypeColor' => !empty(trim($row['TaskTypeColor'] ?? '')) ? $row['TaskTypeColor'] : '#9C27B0', + 'ServicePointID' => (int) ($row['ServicePointID'] ?? 0), + 'ServicePointName' => $row['ServicePointName'] ?? '', + 'CustomerID' => (int) ($row['UserID'] ?? 0), + 'CustomerName' => $customerName, + 'OrderTotal' => 0, + ]; + } + + // Calculate OrderTotal for tasks with linked orders + foreach ($tasks as &$task) { + if ((int) $task['SourceID'] > 0) { + $qOT = queryOne(" + SELECT SUM(oli.Price * oli.Quantity) AS Subtotal, o.TipAmount, o.DeliveryFee, o.OrderTypeID, b.TaxRate, b.PayfritFee + FROM Orders o + INNER JOIN Businesses b ON b.ID = o.BusinessID + LEFT JOIN OrderLineItems oli ON oli.OrderID = o.ID AND oli.IsDeleted = 0 + WHERE o.ID = ? + GROUP BY o.ID + ", [$task['SourceID']]); + if ($qOT) { + $sub = (float) $qOT['Subtotal']; + $feeRate = (is_numeric($qOT['PayfritFee']) && (float) $qOT['PayfritFee'] > 0) ? (float) $qOT['PayfritFee'] : 0.05; + $total = $sub + ($sub * (float) $qOT['TaxRate']) + (float) $qOT['TipAmount'] + + (((int) $qOT['OrderTypeID'] === 3) ? (float) $qOT['DeliveryFee'] : 0) + + ($sub * $feeRate); + $task['OrderTotal'] = number_format($total, 2, '.', ''); + } + } + } + unset($task); + + jsonResponse([ + 'OK' => true, + 'ERROR' => '', + 'TASKS' => $tasks, + 'COUNT' => count($tasks), + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => 'Error loading tasks', 'DETAIL' => $e->getMessage()]); +} diff --git a/api/tasks/listPending.php b/api/tasks/listPending.php new file mode 100644 index 0000000..084efaf --- /dev/null +++ b/api/tasks/listPending.php @@ -0,0 +1,118 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'BusinessID is required.']); +} + +try { + // Get business name + $qBusiness = queryOne("SELECT Name FROM Businesses WHERE ID = ?", [$businessID]); + $businessName = $qBusiness ? $qBusiness['Name'] : ''; + + // Build WHERE clause + $where = "t.BusinessID = ? AND t.ClaimedByUserID = 0 AND t.CompletedOn IS NULL"; + $params = [$businessID]; + + if ($taskTypeID > 0) { + $where .= " AND t.TaskTypeID = ?"; + $params[] = $taskTypeID; + } + + $qTasks = queryTimed(" + SELECT + t.ID, t.BusinessID, t.OrderID, t.TaskTypeID, + t.Title, t.Details, t.CreatedOn, t.ClaimedByUserID, t.UserID, + tt.Name AS TaskTypeName, tt.Icon AS TaskTypeIcon, tt.Color AS TaskTypeColor, + t.ServicePointID, + sp.Name AS ServicePointName, + u.FirstName AS CustomerFirstName, + u.LastName AS CustomerLastName + FROM Tasks t + LEFT JOIN tt_TaskTypes tt ON tt.ID = t.TaskTypeID + LEFT JOIN Orders o ON o.ID = t.OrderID + LEFT JOIN ServicePoints sp ON sp.ID = COALESCE(t.ServicePointID, o.ServicePointID) + LEFT JOIN Users u ON u.ID = t.UserID + WHERE $where + ORDER BY t.CreatedOn ASC + ", $params); + + $tasks = []; + foreach ($qTasks as $row) { + $taskTitle = !empty(trim($row['Title'] ?? '')) + ? $row['Title'] + : ((int) $row['OrderID'] > 0 ? "Order #" . $row['OrderID'] : "Task #" . $row['ID']); + + $customerName = ''; + if (!empty(trim($row['CustomerFirstName'] ?? ''))) { + $customerName = $row['CustomerFirstName']; + if (!empty(trim($row['CustomerLastName'] ?? ''))) { + $customerName .= ' ' . $row['CustomerLastName']; + } + } + + $tasks[] = [ + 'TaskID' => (int) $row['ID'], + 'BusinessID' => (int) $row['BusinessID'], + 'TaskTypeID' => (int) $row['TaskTypeID'], + 'Title' => $taskTitle, + 'Details' => !empty(trim($row['Details'] ?? '')) ? $row['Details'] : '', + 'CreatedOn' => toISO8601($row['CreatedOn']), + 'StatusID' => (int) $row['ClaimedByUserID'] > 0 ? 1 : 0, + 'SourceType' => 'order', + 'SourceID' => (int) $row['OrderID'], + 'TaskTypeName' => !empty(trim($row['TaskTypeName'] ?? '')) ? $row['TaskTypeName'] : '', + 'TaskTypeIcon' => !empty(trim($row['TaskTypeIcon'] ?? '')) ? $row['TaskTypeIcon'] : 'notifications', + 'TaskTypeColor' => !empty(trim($row['TaskTypeColor'] ?? '')) ? $row['TaskTypeColor'] : '#9C27B0', + 'ServicePointID' => (int) ($row['ServicePointID'] ?? 0), + 'ServicePointName' => $row['ServicePointName'] ?? '', + 'CustomerID' => (int) ($row['UserID'] ?? 0), + 'CustomerName' => $customerName, + 'OrderTotal' => 0, + ]; + } + + // Calculate OrderTotal for tasks with linked orders + foreach ($tasks as &$task) { + if ((int) $task['SourceID'] > 0) { + $qOT = queryOne(" + SELECT SUM(oli.Price * oli.Quantity) AS Subtotal, o.TipAmount, o.DeliveryFee, o.OrderTypeID, b.TaxRate, b.PayfritFee + FROM Orders o + INNER JOIN Businesses b ON b.ID = o.BusinessID + LEFT JOIN OrderLineItems oli ON oli.OrderID = o.ID AND oli.IsDeleted = 0 + WHERE o.ID = ? + GROUP BY o.ID + ", [$task['SourceID']]); + if ($qOT) { + $sub = (float) $qOT['Subtotal']; + $feeRate = (is_numeric($qOT['PayfritFee']) && (float) $qOT['PayfritFee'] > 0) ? (float) $qOT['PayfritFee'] : 0.05; + $total = $sub + ($sub * (float) $qOT['TaxRate']) + (float) $qOT['TipAmount'] + + (((int) $qOT['OrderTypeID'] === 3) ? (float) $qOT['DeliveryFee'] : 0) + + ($sub * $feeRate); + $task['OrderTotal'] = number_format($total, 2, '.', ''); + } + } + } + unset($task); + + jsonResponse([ + 'OK' => true, + 'ERROR' => '', + 'TASKS' => $tasks, + 'COUNT' => count($tasks), + 'BUSINESS_NAME' => $businessName, + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => 'Error loading tasks', 'DETAIL' => $e->getMessage()]); +} diff --git a/api/tasks/listTypes.php b/api/tasks/listTypes.php new file mode 100644 index 0000000..48ee16a --- /dev/null +++ b/api/tasks/listTypes.php @@ -0,0 +1,56 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'BusinessID is required']); +} + +try { + $q = queryTimed(" + SELECT + ID as TaskTypeID, + Name as TaskTypeName, + Description as TaskTypeDescription, + Icon as TaskTypeIcon, + Color as TaskTypeColor, + RequiresServicePoint, + SortOrder + FROM tt_TaskTypes + WHERE BusinessID = ? + ORDER BY SortOrder, ID + ", [$bizID]); + + $taskTypes = []; + foreach ($q as $row) { + $taskTypes[] = [ + 'TaskTypeID' => (int) $row['TaskTypeID'], + 'TaskTypeName' => $row['TaskTypeName'], + 'TaskTypeDescription' => $row['TaskTypeDescription'] ?? '', + 'TaskTypeIcon' => $row['TaskTypeIcon'] ?? 'notifications', + 'TaskTypeColor' => $row['TaskTypeColor'] ?? '#9C27B0', + 'RequiresServicePoint' => ((int) ($row['RequiresServicePoint'] ?? 0)) === 1, + ]; + } + + jsonResponse([ + 'OK' => true, + 'TASK_TYPES' => $taskTypes, + 'COUNT' => count($taskTypes), + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/tasks/reorderTypes.php b/api/tasks/reorderTypes.php new file mode 100644 index 0000000..8343399 --- /dev/null +++ b/api/tasks/reorderTypes.php @@ -0,0 +1,38 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'BusinessID is required']); +} + +if (!isset($data['Order']) || !is_array($data['Order'])) { + apiAbort(['OK' => false, 'ERROR' => 'missing_params', 'MESSAGE' => 'Order array is required']); +} + +try { + $sortOrder = 0; + foreach ($data['Order'] as $taskTypeID) { + if (is_numeric($taskTypeID)) { + $sortOrder++; + queryTimed(" + UPDATE tt_TaskTypes + SET SortOrder = ? + WHERE ID = ? AND BusinessID = ? + ", [$sortOrder, (int) $taskTypeID, $businessID]); + } + } + + jsonResponse(['OK' => true, 'MESSAGE' => 'Sort order updated']); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/tasks/saveCategory.php b/api/tasks/saveCategory.php new file mode 100644 index 0000000..2116616 --- /dev/null +++ b/api/tasks/saveCategory.php @@ -0,0 +1,68 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'BusinessID is required']); +} + +try { + $categoryID = (int) ($data['TaskCategoryID'] ?? 0); + $categoryName = trim($data['Name'] ?? ''); + $categoryColor = trim($data['Color'] ?? '#6366f1'); + + if (empty($categoryName)) { + apiAbort(['OK' => false, 'ERROR' => 'missing_params', 'MESSAGE' => 'Name is required']); + } + + // Validate color format - accept #RRGGBB or RRGGBB + if (!preg_match('/^#[0-9A-Fa-f]{6}$/', $categoryColor)) { + if (preg_match('/^[0-9A-Fa-f]{6}$/', $categoryColor)) { + $categoryColor = '#' . $categoryColor; + } else { + $categoryColor = '#6366f1'; + } + } + + if ($categoryID > 0) { + // UPDATE + $qCheck = queryOne(" + SELECT ID FROM TaskCategories WHERE ID = ? AND BusinessID = ? + ", [$categoryID, $bizID]); + + if (!$qCheck) { + apiAbort(['OK' => false, 'ERROR' => 'not_found', 'MESSAGE' => 'Category not found']); + } + + queryTimed(" + UPDATE TaskCategories SET Name = ?, Color = ? WHERE ID = ? + ", [$categoryName, $categoryColor, $categoryID]); + + jsonResponse(['OK' => true, 'CATEGORY_ID' => $categoryID, 'MESSAGE' => 'Category updated']); + } else { + // INSERT + queryTimed(" + INSERT INTO TaskCategories (BusinessID, Name, Color) + VALUES (?, ?, ?) + ", [$bizID, $categoryName, $categoryColor]); + + $newID = (int) lastInsertId(); + jsonResponse(['OK' => true, 'CATEGORY_ID' => $newID, 'MESSAGE' => 'Category created']); + } + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/tasks/saveType.php b/api/tasks/saveType.php new file mode 100644 index 0000000..9bf92db --- /dev/null +++ b/api/tasks/saveType.php @@ -0,0 +1,106 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'BusinessID is required']); +} + +try { + $taskTypeName = trim($data['TaskTypeName'] ?? ''); + if (empty($taskTypeName)) { + apiAbort(['OK' => false, 'ERROR' => 'missing_params', 'MESSAGE' => 'TaskTypeName is required']); + } + if (strlen($taskTypeName) > 45) { + apiAbort(['OK' => false, 'ERROR' => 'invalid_params', 'MESSAGE' => 'TaskTypeName must be 45 characters or less']); + } + + $taskTypeDescription = trim($data['TaskTypeDescription'] ?? ''); + if (strlen($taskTypeDescription) > 100) { + apiAbort(['OK' => false, 'ERROR' => 'invalid_params', 'MESSAGE' => 'TaskTypeDescription must be 100 characters or less']); + } + + $taskTypeIcon = trim($data['TaskTypeIcon'] ?? '') ?: 'notifications'; + if (strlen($taskTypeIcon) > 30) { + apiAbort(['OK' => false, 'ERROR' => 'invalid_params', 'MESSAGE' => 'TaskTypeIcon must be 30 characters or less']); + } + + $taskTypeColor = trim($data['TaskTypeColor'] ?? '') ?: '#9C27B0'; + if ($taskTypeColor[0] !== '#') { + $taskTypeColor = '#' . $taskTypeColor; + } + if (strlen($taskTypeColor) > 7) { + apiAbort(['OK' => false, 'ERROR' => 'invalid_params', 'MESSAGE' => 'TaskTypeColor must be a valid hex color']); + } + + $requiresServicePoint = 1; + if (isset($data['RequiresServicePoint'])) { + $requiresServicePoint = $data['RequiresServicePoint'] ? 1 : 0; + } + + $categoryID = null; + if (isset($data['TaskTypeCategoryID']) && is_numeric($data['TaskTypeCategoryID']) && (int) $data['TaskTypeCategoryID'] > 0) { + $categoryID = (int) $data['TaskTypeCategoryID']; + } elseif (isset($data['CategoryID']) && is_numeric($data['CategoryID']) && (int) $data['CategoryID'] > 0) { + $categoryID = (int) $data['CategoryID']; + } + + $taskTypeID = (int) ($data['TaskTypeID'] ?? 0); + + if ($taskTypeID > 0) { + // UPDATE + $qCheck = queryOne(" + SELECT ID FROM tt_TaskTypes WHERE ID = ? AND BusinessID = ? + ", [$taskTypeID, $businessID]); + + if (!$qCheck) { + apiAbort(['OK' => false, 'ERROR' => 'not_found', 'MESSAGE' => 'Task type not found or does not belong to this business']); + } + + queryTimed(" + UPDATE tt_TaskTypes + SET Name = ?, Description = ?, Icon = ?, Color = ?, + RequiresServicePoint = ?, TaskCategoryID = ? + WHERE ID = ? + ", [ + $taskTypeName, + !empty($taskTypeDescription) ? $taskTypeDescription : null, + $taskTypeIcon, + $taskTypeColor, + $requiresServicePoint, + $categoryID, + $taskTypeID, + ]); + + jsonResponse(['OK' => true, 'TASK_TYPE_ID' => $taskTypeID, 'MESSAGE' => 'Task type updated']); + } else { + // INSERT + queryTimed(" + INSERT INTO tt_TaskTypes (Name, Description, Icon, Color, RequiresServicePoint, BusinessID, TaskCategoryID) + VALUES (?, ?, ?, ?, ?, ?, ?) + ", [ + $taskTypeName, + !empty($taskTypeDescription) ? $taskTypeDescription : null, + $taskTypeIcon, + $taskTypeColor, + $requiresServicePoint, + $businessID, + $categoryID, + ]); + + $newID = (int) lastInsertId(); + jsonResponse(['OK' => true, 'TASK_TYPE_ID' => $newID, 'MESSAGE' => 'Task type created']); + } + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/tasks/seedCategories.php b/api/tasks/seedCategories.php new file mode 100644 index 0000000..9ddc8ed --- /dev/null +++ b/api/tasks/seedCategories.php @@ -0,0 +1,86 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'BusinessID is required']); +} + +try { + // Check if categories already exist + $qCheck = queryOne("SELECT COUNT(*) AS cnt FROM TaskCategories WHERE BusinessID = ?", [$bizID]); + + if ((int) $qCheck['cnt'] > 0) { + $q = queryTimed(" + SELECT ID, Name, Color FROM TaskCategories + WHERE BusinessID = ? AND IsActive = 1 ORDER BY Name + ", [$bizID]); + + $categories = []; + foreach ($q as $row) { + $categories[] = [ + 'TaskCategoryID' => (int) $row['ID'], + 'Name' => $row['Name'], + 'Color' => $row['Color'], + ]; + } + + jsonResponse(['OK' => true, 'MESSAGE' => 'Categories already exist', 'CATEGORIES' => $categories]); + } + + // Default categories + $defaults = [ + ['Service Point', '#F44336'], + ['Kitchen', '#FF9800'], + ['Bar', '#9C27B0'], + ['Cleaning', '#4CAF50'], + ['Management', '#2196F3'], + ['Delivery', '#00BCD4'], + ['General', '#607D8B'], + ]; + + foreach ($defaults as [$name, $color]) { + queryTimed(" + INSERT INTO TaskCategories (BusinessID, Name, Color) + VALUES (?, ?, ?) + ", [$bizID, $name, $color]); + } + + // Return created categories + $q = queryTimed(" + SELECT ID, Name, Color FROM TaskCategories + WHERE BusinessID = ? AND IsActive = 1 ORDER BY Name + ", [$bizID]); + + $categories = []; + foreach ($q as $row) { + $categories[] = [ + 'TaskCategoryID' => (int) $row['ID'], + 'Name' => $row['Name'], + 'Color' => $row['Color'], + ]; + } + + jsonResponse([ + 'OK' => true, + 'MESSAGE' => 'Created ' . count($defaults) . ' default categories', + 'CATEGORIES' => $categories, + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/users/search.php b/api/users/search.php new file mode 100644 index 0000000..2bbca8d --- /dev/null +++ b/api/users/search.php @@ -0,0 +1,56 @@ + true, 'USERS' => [], 'COUNT' => 0, 'MESSAGE' => 'Query must be at least 3 characters']); +} + +$searchTerm = '%' . $query . '%'; + +$rows = queryTimed( + "SELECT u.ID, u.FirstName, u.LastName, u.EmailAddress, u.ContactNumber, u.ImageExtension + FROM Users u + WHERE u.ID != ? + AND ( + u.ContactNumber LIKE ? + OR u.EmailAddress LIKE ? + OR u.FirstName LIKE ? + OR u.LastName LIKE ? + OR CONCAT(u.FirstName, ' ', u.LastName) LIKE ? + ) + ORDER BY u.FirstName, u.LastName + LIMIT 10", + [$currentUserId, $searchTerm, $searchTerm, $searchTerm, $searchTerm, $searchTerm] +); + +$users = []; +foreach ($rows as $r) { + $maskedPhone = ''; + $phone = trim($r['ContactNumber'] ?? ''); + if (strlen($phone) >= 4) { + $maskedPhone = '***-***-' . substr($phone, -4); + } + + $users[] = [ + 'UserID' => (int) $r['ID'], + 'Name' => trim($r['FirstName'] . ' ' . $r['LastName']), + 'Email' => $r['EmailAddress'] ?? '', + 'Phone' => $maskedPhone, + 'AvatarUrl' => !empty(trim($r['ImageExtension'] ?? '')) + ? baseUrl() . '/uploads/users/' . $r['ID'] . '.' . $r['ImageExtension'] + : '', + ]; +} + +jsonResponse(['OK' => true, 'USERS' => $users, 'COUNT' => count($users)]); diff --git a/api/workers/createAccount.php b/api/workers/createAccount.php new file mode 100644 index 0000000..4c7a1ec --- /dev/null +++ b/api/workers/createAccount.php @@ -0,0 +1,84 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'UserID is required.']); +} + +try { + $qUser = queryOne(" + SELECT StripeConnectedAccountID, EmailAddress, FirstName, LastName + FROM Users WHERE ID = ? + ", [$userID]); + + if (!$qUser) { + apiAbort(['OK' => false, 'ERROR' => 'user_not_found']); + } + + $existingAccountID = trim($qUser['StripeConnectedAccountID'] ?? ''); + + if (!empty($existingAccountID)) { + jsonResponse([ + 'OK' => true, + 'ACCOUNT_ID' => $existingAccountID, + 'CREATED' => false, + ]); + } + + // Create new Stripe Connect Express account + $stripeSecretKey = getenv('STRIPE_SECRET_KEY') ?: ''; + + $postFields = [ + 'type' => 'express', + 'country' => 'US', + 'capabilities[transfers][requested]' => 'true', + 'metadata[user_id]' => $userID, + ]; + + $userEmail = trim($qUser['EmailAddress'] ?? ''); + if (!empty($userEmail)) { + $postFields['email'] = $userEmail; + } + + $ch = curl_init('https://api.stripe.com/v1/accounts'); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => http_build_query($postFields), + CURLOPT_USERPWD => $stripeSecretKey . ':', + CURLOPT_RETURNTRANSFER => true, + ]); + $result = curl_exec($ch); + curl_close($ch); + + $acctData = json_decode($result, true); + + if (isset($acctData['error'])) { + apiAbort(['OK' => false, 'ERROR' => $acctData['error']['message']]); + } + + $newAccountID = $acctData['id']; + + // Save to Users table + queryTimed("UPDATE Users SET StripeConnectedAccountID = ? WHERE ID = ?", [$newAccountID, $userID]); + + jsonResponse([ + 'OK' => true, + 'ACCOUNT_ID' => $newAccountID, + 'CREATED' => true, + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => $e->getMessage()]); +} diff --git a/api/workers/earlyUnlock.php b/api/workers/earlyUnlock.php new file mode 100644 index 0000000..bf22276 --- /dev/null +++ b/api/workers/earlyUnlock.php @@ -0,0 +1,78 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'UserID is required.']); +} + +try { + $qUser = queryOne(" + SELECT ActivationBalanceCents, ActivationCapCents + FROM Users WHERE ID = ? + ", [$userID]); + + if (!$qUser) { + apiAbort(['OK' => false, 'ERROR' => 'user_not_found']); + } + + $remainingCents = (int) $qUser['ActivationCapCents'] - (int) $qUser['ActivationBalanceCents']; + + if ($remainingCents <= 0) { + jsonResponse([ + 'OK' => true, + 'ALREADY_COMPLETE' => true, + ]); + } + + $stripeSecretKey = getenv('STRIPE_SECRET_KEY') ?: ''; + $base = baseUrl(); + + $postFields = [ + 'mode' => 'payment', + 'line_items[0][price_data][unit_amount]' => $remainingCents, + 'line_items[0][price_data][currency]' => 'usd', + 'line_items[0][price_data][product_data][name]' => 'Payfrit Activation', + 'line_items[0][quantity]' => '1', + 'success_url' => $base . '/works/stripe-return.cfm?status=success', + 'cancel_url' => $base . '/works/stripe-return.cfm?status=cancel', + 'metadata[user_id]' => $userID, + 'metadata[type]' => 'activation_unlock', + ]; + + $ch = curl_init('https://api.stripe.com/v1/checkout/sessions'); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => http_build_query($postFields), + CURLOPT_USERPWD => $stripeSecretKey . ':', + CURLOPT_RETURNTRANSFER => true, + ]); + $result = curl_exec($ch); + curl_close($ch); + + $sessionData = json_decode($result, true); + + if (isset($sessionData['error'])) { + apiAbort(['OK' => false, 'ERROR' => $sessionData['error']['message']]); + } + + jsonResponse([ + 'OK' => true, + 'CHECKOUT_URL' => $sessionData['url'], + 'AMOUNT_DUE_CENTS' => $remainingCents, + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => $e->getMessage()]); +} diff --git a/api/workers/ledger.php b/api/workers/ledger.php new file mode 100644 index 0000000..93f7109 --- /dev/null +++ b/api/workers/ledger.php @@ -0,0 +1,66 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'UserID is required.']); +} + +try { + // Get ledger entries (most recent first) + $qLedger = queryTimed(" + SELECT ID, TaskID, GrossEarningsCents, ActivationWithheldCents, + NetTransferCents, Status, CreatedAt + FROM WorkPayoutLedgers + WHERE UserID = ? + ORDER BY CreatedAt DESC + LIMIT 100 + ", [$userID]); + + // Get totals + $qTotals = queryOne(" + SELECT + COALESCE(SUM(GrossEarningsCents), 0) AS TotalGrossCents, + COALESCE(SUM(ActivationWithheldCents), 0) AS TotalWithheldCents, + COALESCE(SUM(CASE WHEN Status = 'transferred' THEN NetTransferCents ELSE 0 END), 0) AS TotalTransferredCents + FROM WorkPayoutLedgers + WHERE UserID = ? + ", [$userID]); + + $ledgerEntries = []; + foreach ($qLedger as $row) { + $ledgerEntries[] = [ + 'ID' => (int) $row['ID'], + 'TaskID' => (int) $row['TaskID'], + 'GrossEarningsCents' => (int) $row['GrossEarningsCents'], + 'ActivationWithheldCents' => (int) $row['ActivationWithheldCents'], + 'NetTransferCents' => (int) $row['NetTransferCents'], + 'Status' => $row['Status'], + 'CreatedAt' => toISO8601($row['CreatedAt']), + ]; + } + + jsonResponse([ + 'OK' => true, + 'LEDGER' => $ledgerEntries, + 'TOTALS' => [ + 'TotalGrossCents' => (int) ($qTotals['TotalGrossCents'] ?? 0), + 'TotalWithheldCents' => (int) ($qTotals['TotalWithheldCents'] ?? 0), + 'TotalTransferredCents' => (int) ($qTotals['TotalTransferredCents'] ?? 0), + ], + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => $e->getMessage()]); +} diff --git a/api/workers/myBusinesses.php b/api/workers/myBusinesses.php new file mode 100644 index 0000000..bbbc6e9 --- /dev/null +++ b/api/workers/myBusinesses.php @@ -0,0 +1,59 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'UserID is required.']); +} + +try { + $qBusinesses = queryTimed(" + SELECT + MIN(e.ID) AS EmployeeID, + e.BusinessID, + MIN(e.StatusID) AS StatusID, + MAX(e.IsActive) AS IsActive, + MAX(COALESCE(e.RoleID, 1)) AS RoleID, + b.Name AS Name, + (SELECT COUNT(*) FROM Tasks t WHERE t.BusinessID = e.BusinessID AND t.ClaimedByUserID = 0 AND t.CompletedOn IS NULL) AS PendingTaskCount, + (SELECT COUNT(*) FROM Tasks t WHERE t.BusinessID = e.BusinessID AND t.ClaimedByUserID = ? AND t.CompletedOn IS NULL) AS ActiveTaskCount + FROM Employees e + INNER JOIN Businesses b ON b.ID = e.BusinessID + WHERE e.UserID = ? AND e.IsActive = 1 + GROUP BY e.BusinessID, b.Name + ORDER BY b.Name ASC + ", [$userID, $userID]); + + $businesses = []; + foreach ($qBusinesses as $row) { + $businesses[] = [ + 'EmployeeID' => (int) $row['EmployeeID'], + 'BusinessID' => (int) $row['BusinessID'], + 'Name' => $row['Name'], + 'Address' => '', + 'City' => '', + 'StatusID' => (int) $row['StatusID'], + 'RoleID' => (int) $row['RoleID'], + 'PendingTaskCount' => (int) $row['PendingTaskCount'], + 'ActiveTaskCount' => (int) $row['ActiveTaskCount'], + ]; + } + + jsonResponse([ + 'OK' => true, + 'ERROR' => '', + 'BUSINESSES' => $businesses, + 'COUNT' => count($businesses), + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => 'Error loading businesses', 'DETAIL' => $e->getMessage()]); +} diff --git a/api/workers/onboardingLink.php b/api/workers/onboardingLink.php new file mode 100644 index 0000000..cdec3c0 --- /dev/null +++ b/api/workers/onboardingLink.php @@ -0,0 +1,66 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'UserID is required.']); +} + +try { + $qUser = queryOne("SELECT StripeConnectedAccountID FROM Users WHERE ID = ?", [$userID]); + + if (!$qUser) { + apiAbort(['OK' => false, 'ERROR' => 'user_not_found']); + } + + $accountID = trim($qUser['StripeConnectedAccountID'] ?? ''); + + if (empty($accountID)) { + apiAbort(['OK' => false, 'ERROR' => 'no_stripe_account', 'MESSAGE' => 'Create a Stripe account first.']); + } + + $stripeSecretKey = getenv('STRIPE_SECRET_KEY') ?: ''; + $base = baseUrl(); + + $postFields = [ + 'account' => $accountID, + 'refresh_url' => $base . '/works/stripe-return.cfm?status=refresh', + 'return_url' => $base . '/works/stripe-return.cfm?status=complete', + 'type' => 'account_onboarding', + ]; + + $ch = curl_init('https://api.stripe.com/v1/account_links'); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => http_build_query($postFields), + CURLOPT_USERPWD => $stripeSecretKey . ':', + CURLOPT_RETURNTRANSFER => true, + ]); + $result = curl_exec($ch); + curl_close($ch); + + $linkData = json_decode($result, true); + + if (isset($linkData['error'])) { + apiAbort(['OK' => false, 'ERROR' => $linkData['error']['message']]); + } + + jsonResponse([ + 'OK' => true, + 'ONBOARDING_URL' => $linkData['url'], + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => $e->getMessage()]); +} diff --git a/api/workers/tierStatus.php b/api/workers/tierStatus.php new file mode 100644 index 0000000..a466b91 --- /dev/null +++ b/api/workers/tierStatus.php @@ -0,0 +1,59 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'UserID is required.']); +} + +try { + $qUser = queryOne(" + SELECT StripeConnectedAccountID, StripePayoutsEnabled, + ActivationBalanceCents, ActivationCapCents + FROM Users WHERE ID = ? + ", [$userID]); + + if (!$qUser) { + apiAbort(['OK' => false, 'ERROR' => 'user_not_found']); + } + + $payoutsEnabled = ((int) ($qUser['StripePayoutsEnabled'] ?? 0)) === 1; + $hasAccount = !empty(trim($qUser['StripeConnectedAccountID'] ?? '')); + $balanceCents = (int) ($qUser['ActivationBalanceCents'] ?? 0); + $capCents = (int) ($qUser['ActivationCapCents'] ?? 0); + $remainingCents = max(0, $capCents - $balanceCents); + $isComplete = ($remainingCents === 0); + $progressPercent = $capCents > 0 ? (int) round(($balanceCents / $capCents) * 100) : 100; + if ($progressPercent > 100) $progressPercent = 100; + + jsonResponse([ + 'OK' => true, + 'TIER' => $payoutsEnabled ? 1 : 0, + 'STRIPE' => [ + 'HasAccount' => $hasAccount, + 'PayoutsEnabled' => $payoutsEnabled, + 'SetupIncomplete' => $hasAccount && !$payoutsEnabled, + ], + 'ACTIVATION' => [ + 'BalanceCents' => $balanceCents, + 'CapCents' => $capCents, + 'RemainingCents' => $remainingCents, + 'IsComplete' => $isComplete, + 'ProgressPercent' => $progressPercent, + ], + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => $e->getMessage()]); +}