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.
This commit is contained in:
commit
1f81d98c52
167 changed files with 17800 additions and 0 deletions
72
api/addresses/add.php
Normal file
72
api/addresses/add.php
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
global $userId;
|
||||
|
||||
if ($userId <= 0) {
|
||||
apiAbort(['OK' => 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()]);
|
||||
}
|
||||
38
api/addresses/delete.php
Normal file
38
api/addresses/delete.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
global $userId;
|
||||
|
||||
if ($userId <= 0) {
|
||||
apiAbort(['OK' => 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()]);
|
||||
}
|
||||
49
api/addresses/list.php
Normal file
49
api/addresses/list.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
global $userId;
|
||||
|
||||
if ($userId <= 0) {
|
||||
apiAbort(['OK' => 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()]);
|
||||
}
|
||||
39
api/addresses/setDefault.php
Normal file
39
api/addresses/setDefault.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
global $userId;
|
||||
|
||||
if ($userId <= 0) {
|
||||
apiAbort(['OK' => 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()]);
|
||||
}
|
||||
24
api/addresses/states.php
Normal file
24
api/addresses/states.php
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
try {
|
||||
$rows = queryTimed("
|
||||
SELECT ID AS StateID, Abbreviation AS StateAbbreviation, Name AS StateName
|
||||
FROM tt_States ORDER BY Name
|
||||
", []);
|
||||
|
||||
$states = [];
|
||||
foreach ($rows as $r) {
|
||||
$states[] = [
|
||||
'StateID' => (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()]);
|
||||
}
|
||||
20
api/addresses/types.php
Normal file
20
api/addresses/types.php
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
try {
|
||||
$rows = queryTimed("
|
||||
SELECT tt_AddressTypeID AS ID, tt_AddressType AS Label
|
||||
FROM tt_AddressTypes ORDER BY tt_AddressTypeID
|
||||
", []);
|
||||
|
||||
$types = [];
|
||||
foreach ($rows as $r) {
|
||||
$types[] = ['ID' => (int) $r['ID'], 'Label' => $r['Label']];
|
||||
}
|
||||
|
||||
jsonResponse(['OK' => true, 'TYPES' => $types]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]);
|
||||
}
|
||||
47
api/app/about.php
Normal file
47
api/app/about.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
$features = [
|
||||
[
|
||||
'ICON' => '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.',
|
||||
]);
|
||||
43
api/assignments/delete.php
Normal file
43
api/assignments/delete.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
global $businessId;
|
||||
|
||||
if ($businessId <= 0) {
|
||||
apiAbort(['OK' => 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,
|
||||
]);
|
||||
68
api/assignments/list.php
Normal file
68
api/assignments/list.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
global $businessId;
|
||||
|
||||
if ($businessId <= 0) {
|
||||
apiAbort(['OK' => 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,
|
||||
]);
|
||||
67
api/assignments/save.php
Normal file
67
api/assignments/save.php
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
global $businessId;
|
||||
|
||||
if ($businessId <= 0) {
|
||||
apiAbort(['OK' => 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,
|
||||
]);
|
||||
110
api/auth/avatar.php
Normal file
110
api/auth/avatar.php
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/*
|
||||
Avatar Upload/Get API
|
||||
GET: Returns avatar URL for authenticated user
|
||||
POST: Uploads new avatar image (multipart form data)
|
||||
Stores images as: /uploads/users/{UserID}.jpg
|
||||
*/
|
||||
|
||||
global $userId;
|
||||
|
||||
if ($userId <= 0) {
|
||||
apiAbort(['OK' => 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']);
|
||||
66
api/auth/completeProfile.php
Normal file
66
api/auth/completeProfile.php
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/*
|
||||
Complete user profile after phone verification
|
||||
POST: { "firstName": "John", "lastName": "Smith", "email": "john@example.com" }
|
||||
Requires auth token (X-User-Token header)
|
||||
Returns: { OK: true }
|
||||
*/
|
||||
|
||||
global $userId;
|
||||
|
||||
if ($userId <= 0) {
|
||||
apiAbort(['OK' => 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]);
|
||||
50
api/auth/login.php
Normal file
50
api/auth/login.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/*
|
||||
POST: { "username": "...", "password": "..." }
|
||||
Returns: { OK: true, ERROR: "", UserID: 123, FirstName: "...", Token: "..." }
|
||||
*/
|
||||
|
||||
function normalizeUsername(string $u): string {
|
||||
return str_replace([' ', '(', ')', '-'], '', trim($u));
|
||||
}
|
||||
|
||||
$data = readJsonBody();
|
||||
$username = normalizeUsername($data['username'] ?? '');
|
||||
$password = $data['password'] ?? '';
|
||||
|
||||
if (empty($username) || empty($password)) {
|
||||
apiAbort(['OK' => 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,
|
||||
]);
|
||||
56
api/auth/loginOTP.php
Normal file
56
api/auth/loginOTP.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/*
|
||||
Send OTP to phone for LOGIN (existing verified accounts only)
|
||||
POST: { "phone": "5551234567" }
|
||||
Returns: { OK: true, UUID: "..." }
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$phone = normalizePhone($data['Phone'] ?? $data['phone'] ?? '');
|
||||
|
||||
if (strlen($phone) !== 10) {
|
||||
apiAbort(['OK' => 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);
|
||||
13
api/auth/logout.php
Normal file
13
api/auth/logout.php
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
global $userId;
|
||||
|
||||
$token = headerValue('X-User-Token');
|
||||
|
||||
if (!empty($token)) {
|
||||
queryTimed("DELETE FROM UserTokens WHERE Token = ?", [$token]);
|
||||
}
|
||||
|
||||
jsonResponse(['OK' => true]);
|
||||
97
api/auth/profile.php
Normal file
97
api/auth/profile.php
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/*
|
||||
User Profile API
|
||||
GET: Returns current user's profile info
|
||||
POST: Updates profile (firstName, lastName)
|
||||
*/
|
||||
|
||||
global $userId;
|
||||
|
||||
if ($userId <= 0) {
|
||||
apiAbort(['OK' => 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']);
|
||||
88
api/auth/sendLoginOTP.php
Normal file
88
api/auth/sendLoginOTP.php
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/*
|
||||
Send OTP Code for Portal Login
|
||||
POST: { "Email": "user@example.com" } or { "Phone": "3105551234" } or { "Identifier": "..." }
|
||||
Returns: { OK: true } always (don't reveal if account exists)
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$identifier = trim($data['Identifier'] ?? $data['Email'] ?? $data['Phone'] ?? '');
|
||||
|
||||
if (empty($identifier)) {
|
||||
apiAbort(['OK' => 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);
|
||||
}
|
||||
65
api/auth/sendOTP.php
Normal file
65
api/auth/sendOTP.php
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/*
|
||||
Unified OTP: Send OTP to any phone number (login or signup)
|
||||
POST: { "phone": "5551234567" }
|
||||
Returns: { OK: true, UUID: "..." }
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$phone = normalizePhone($data['phone'] ?? '');
|
||||
|
||||
if (strlen($phone) !== 10) {
|
||||
apiAbort(['OK' => 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);
|
||||
46
api/auth/validateToken.php
Normal file
46
api/auth/validateToken.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/*
|
||||
Validate a user token (for WebSocket server authentication)
|
||||
POST: { "Token": "..." }
|
||||
Returns: { OK: true, UserID: ..., UserType: "customer"/"worker", UserName: "..." }
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$token = trim($data['Token'] ?? '');
|
||||
|
||||
if (empty($token)) {
|
||||
apiAbort(['OK' => 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']),
|
||||
]);
|
||||
78
api/auth/verifyEmailOTP.php
Normal file
78
api/auth/verifyEmailOTP.php
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/*
|
||||
Verify OTP Code for Portal Login (supports email or phone)
|
||||
POST: { "Email": "user@example.com", "Code": "123456" }
|
||||
or { "Phone": "3105551234", "Code": "123456" }
|
||||
or { "Identifier": "...", "Code": "123456" }
|
||||
Returns: { OK: true, UserID, FirstName, Token }
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$identifier = trim($data['Identifier'] ?? $data['Email'] ?? $data['Phone'] ?? '');
|
||||
$code = trim($data['Code'] ?? '');
|
||||
|
||||
if (empty($identifier) || empty($code)) {
|
||||
apiAbort(['OK' => 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,
|
||||
]);
|
||||
49
api/auth/verifyLoginOTP.php
Normal file
49
api/auth/verifyLoginOTP.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/*
|
||||
Verify OTP for LOGIN (existing verified accounts only)
|
||||
POST: { "uuid": "...", "otp": "123456" }
|
||||
Returns: { OK: true, UserID: 123, Token: "...", FirstName: "..." }
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$userUUID = trim($data['uuid'] ?? '');
|
||||
$otp = trim($data['otp'] ?? '');
|
||||
|
||||
if (empty($userUUID) || empty($otp)) {
|
||||
apiAbort(['OK' => 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'] ?? '',
|
||||
]);
|
||||
60
api/auth/verifyOTP.php
Normal file
60
api/auth/verifyOTP.php
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/*
|
||||
Unified OTP Verify: Works for both login and signup
|
||||
POST: { "uuid": "...", "otp": "123456" }
|
||||
Returns: { OK: true, UserID: 123, Token: "...", NeedsProfile: true/false }
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$userUUID = trim($data['uuid'] ?? '');
|
||||
$otp = trim($data['otp'] ?? '');
|
||||
|
||||
if (empty($userUUID) || empty($otp)) {
|
||||
apiAbort(['OK' => 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,
|
||||
]);
|
||||
74
api/beacon-sharding/allocate_business_namespace.php
Normal file
74
api/beacon-sharding/allocate_business_namespace.php
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
global $businessId;
|
||||
|
||||
$data = readJsonBody();
|
||||
$bizId = $businessId;
|
||||
if ($bizId <= 0) $bizId = (int) ($data['BusinessID'] ?? 0);
|
||||
if ($bizId <= 0) {
|
||||
apiAbort(['OK' => 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()]);
|
||||
}
|
||||
63
api/beacon-sharding/allocate_servicepoint_minor.php
Normal file
63
api/beacon-sharding/allocate_servicepoint_minor.php
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
global $businessId;
|
||||
|
||||
$data = readJsonBody();
|
||||
$bizId = $businessId;
|
||||
if ($bizId <= 0) $bizId = (int) ($data['BusinessID'] ?? 0);
|
||||
if ($bizId <= 0) {
|
||||
apiAbort(['OK' => 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()]);
|
||||
}
|
||||
100
api/beacon-sharding/get_beacon_config.php
Normal file
100
api/beacon-sharding/get_beacon_config.php
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
global $businessId;
|
||||
|
||||
$data = readJsonBody();
|
||||
$bizId = $businessId;
|
||||
if ($bizId <= 0) $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']);
|
||||
}
|
||||
|
||||
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()]);
|
||||
}
|
||||
34
api/beacon-sharding/get_shard_pool.php
Normal file
34
api/beacon-sharding/get_shard_pool.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
try {
|
||||
$sinceId = (int) ($_GET['since'] ?? 0);
|
||||
|
||||
$sql = "SELECT ID, UUID FROM BeaconShards WHERE IsActive = 1";
|
||||
$params = [];
|
||||
if ($sinceId > 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()]);
|
||||
}
|
||||
105
api/beacon-sharding/register_beacon_hardware.php
Normal file
105
api/beacon-sharding/register_beacon_hardware.php
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
$data = readJsonBody();
|
||||
|
||||
$hardwareId = trim($data['HardwareId'] ?? '');
|
||||
if ($hardwareId === '') {
|
||||
apiAbort(['OK' => 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()]);
|
||||
}
|
||||
85
api/beacon-sharding/resolve_business.php
Normal file
85
api/beacon-sharding/resolve_business.php
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
function resolveSingleBiz(string $uuid, int $major): array {
|
||||
$qBiz = queryOne("
|
||||
SELECT b.ID, b.Name, b.BrandColor, b.HeaderImageExtension
|
||||
FROM Businesses b
|
||||
JOIN BeaconShards bs ON b.BeaconShardID = bs.ID
|
||||
WHERE bs.UUID = ? AND b.BeaconMajor = ?
|
||||
LIMIT 1
|
||||
", [$uuid, $major]);
|
||||
|
||||
if (!$qBiz) {
|
||||
return ['Found' => 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()]);
|
||||
}
|
||||
119
api/beacon-sharding/resolve_servicepoint.php
Normal file
119
api/beacon-sharding/resolve_servicepoint.php
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
function resolveSingle(string $uuid, int $major, int $minor): array {
|
||||
$qBiz = queryOne("
|
||||
SELECT b.ID AS BusinessID, b.Name AS BusinessName
|
||||
FROM Businesses b
|
||||
JOIN BeaconShards bs ON b.BeaconShardID = bs.ID
|
||||
WHERE bs.UUID = ? AND b.BeaconMajor = ?
|
||||
LIMIT 1
|
||||
", [$uuid, $major]);
|
||||
|
||||
if (!$qBiz) {
|
||||
return ['Found' => 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()]);
|
||||
}
|
||||
68
api/beacon-sharding/verify_beacon_broadcast.php
Normal file
68
api/beacon-sharding/verify_beacon_broadcast.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
$data = readJsonBody();
|
||||
|
||||
$hardwareId = trim($data['HardwareId'] ?? '');
|
||||
if ($hardwareId === '') apiAbort(['OK' => 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()]);
|
||||
}
|
||||
32
api/beacons/delete.php
Normal file
32
api/beacons/delete.php
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
global $businessId;
|
||||
|
||||
$data = readJsonBody();
|
||||
$bizId = $businessId;
|
||||
if ($bizId <= 0) $bizId = (int) ($data['BusinessID'] ?? 0);
|
||||
if ($bizId <= 0) {
|
||||
apiAbort(['OK' => 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]);
|
||||
39
api/beacons/get.php
Normal file
39
api/beacons/get.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
global $businessId;
|
||||
|
||||
$data = readJsonBody();
|
||||
$beaconID = (int) ($data['BeaconID'] ?? 0);
|
||||
|
||||
if ($beaconID <= 0) {
|
||||
apiAbort(['OK' => 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'],
|
||||
],
|
||||
]);
|
||||
116
api/beacons/getBusinessFromBeacon.php
Normal file
116
api/beacons/getBusinessFromBeacon.php
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
$data = readJsonBody();
|
||||
$beaconId = (int) ($data['BeaconID'] ?? 0);
|
||||
|
||||
if ($beaconId <= 0) {
|
||||
apiAbort(['OK' => 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);
|
||||
77
api/beacons/list.php
Normal file
77
api/beacons/list.php
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
global $businessId;
|
||||
|
||||
$data = readJsonBody();
|
||||
$bizId = $businessId;
|
||||
if ($bizId <= 0) $bizId = (int) ($data['BusinessID'] ?? 0);
|
||||
if ($bizId <= 0) {
|
||||
apiAbort(['OK' => 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,
|
||||
]);
|
||||
22
api/beacons/list_all.php
Normal file
22
api/beacons/list_all.php
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
$rows = queryTimed("
|
||||
SELECT ID, UUID FROM BeaconShards WHERE IsActive = 1 ORDER BY ID
|
||||
", []);
|
||||
|
||||
$items = [];
|
||||
foreach ($rows as $r) {
|
||||
$items[] = [
|
||||
'ShardID' => (int) $r['ID'],
|
||||
'UUID' => $r['UUID'],
|
||||
];
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'ERROR' => '',
|
||||
'SHARDS' => $items,
|
||||
'ITEMS' => $items,
|
||||
]);
|
||||
75
api/beacons/lookup.php
Normal file
75
api/beacons/lookup.php
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
try {
|
||||
$data = readJsonBody();
|
||||
|
||||
if (!isset($data['Beacons']) || !is_array($data['Beacons']) || count($data['Beacons']) === 0) {
|
||||
jsonResponse(['OK' => 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()]);
|
||||
}
|
||||
19
api/beacons/reassign_all.php
Normal file
19
api/beacons/reassign_all.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
try {
|
||||
$targetBusinessID = 44;
|
||||
|
||||
queryTimed("UPDATE Beacons SET BusinessID = ?", [$targetBusinessID]);
|
||||
|
||||
$qCount = queryOne("SELECT COUNT(*) AS cnt FROM Beacons WHERE BusinessID = ?", [$targetBusinessID]);
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'MESSAGE' => "All beacons reassigned to BusinessID $targetBusinessID",
|
||||
'COUNT' => (int) $qCount['cnt'],
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => $e->getMessage()]);
|
||||
}
|
||||
103
api/beacons/save.php
Normal file
103
api/beacons/save.php
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
global $businessId;
|
||||
|
||||
$data = readJsonBody();
|
||||
|
||||
if ($businessId <= 0) {
|
||||
apiAbort(['OK' => 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,
|
||||
]);
|
||||
150
api/businesses/get.php
Normal file
150
api/businesses/get.php
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Get Business Details
|
||||
* POST: { BusinessID: int }
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$businessID = (int) ($data['BusinessID'] ?? 0);
|
||||
|
||||
if ($businessID <= 0) {
|
||||
apiAbort(['OK' => 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()]);
|
||||
}
|
||||
44
api/businesses/getChildren.php
Normal file
44
api/businesses/getChildren.php
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Get Child Businesses (food court children)
|
||||
* POST or GET: { BusinessID: int }
|
||||
*/
|
||||
|
||||
$parentBusinessId = 0;
|
||||
|
||||
// Support GET param or POST body
|
||||
if (!empty($_GET['BusinessID']) && is_numeric($_GET['BusinessID'])) {
|
||||
$parentBusinessId = (int) $_GET['BusinessID'];
|
||||
} else {
|
||||
$data = readJsonBody();
|
||||
$parentBusinessId = (int) ($data['BusinessID'] ?? 0);
|
||||
}
|
||||
|
||||
if ($parentBusinessId <= 0) {
|
||||
apiAbort(['OK' => 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()]);
|
||||
}
|
||||
83
api/businesses/list.php
Normal file
83
api/businesses/list.php
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* List Businesses
|
||||
* POST: { lat?: float, lng?: float }
|
||||
* Returns nearest 50 businesses sorted by distance.
|
||||
*/
|
||||
|
||||
function haversineDistance(float $lat1, float $lng1, float $lat2, float $lng2): float {
|
||||
$R = 3959; // Earth radius in miles
|
||||
$dLat = deg2rad($lat2 - $lat1);
|
||||
$dLng = deg2rad($lng2 - $lng1);
|
||||
$a = sin($dLat / 2) ** 2 +
|
||||
cos(deg2rad($lat1)) * cos(deg2rad($lat2)) *
|
||||
sin($dLng / 2) ** 2;
|
||||
$a = max(0, min(1, $a));
|
||||
$c = 2 * asin(sqrt($a));
|
||||
return $R * $c;
|
||||
}
|
||||
|
||||
try {
|
||||
$data = readJsonBody();
|
||||
$userLat = (float) ($data['lat'] ?? 0);
|
||||
$userLng = (float) ($data['lng'] ?? 0);
|
||||
$hasUserLocation = ($userLat != 0 && $userLng != 0);
|
||||
|
||||
$rows = queryTimed("
|
||||
SELECT
|
||||
b.ID, b.Name, b.ParentBusinessID,
|
||||
(SELECT COUNT(*) FROM Businesses c WHERE c.ParentBusinessID = b.ID) AS ChildCount,
|
||||
a.Latitude AS AddressLat, a.Longitude AS AddressLng,
|
||||
a.City AS AddressCity, a.Line1 AS AddressLine1
|
||||
FROM Businesses b
|
||||
LEFT JOIN Addresses a ON b.AddressID = a.ID
|
||||
WHERE (b.IsDemo = 0 OR b.IsDemo IS NULL)
|
||||
AND (b.IsPrivate = 0 OR b.IsPrivate IS NULL)
|
||||
AND (b.ParentBusinessID IS NULL OR b.ParentBusinessID = 0)
|
||||
ORDER BY b.Name
|
||||
");
|
||||
|
||||
$businesses = [];
|
||||
foreach ($rows as $r) {
|
||||
$bizLat = (float) ($r['AddressLat'] ?? 0);
|
||||
$bizLng = (float) ($r['AddressLng'] ?? 0);
|
||||
|
||||
$distance = 99999;
|
||||
if ($hasUserLocation && $bizLat != 0 && $bizLng != 0) {
|
||||
$distance = haversineDistance($userLat, $userLng, $bizLat, $bizLng);
|
||||
}
|
||||
|
||||
$businesses[] = [
|
||||
'BusinessID' => (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()]);
|
||||
}
|
||||
49
api/businesses/saveBrandColor.php
Normal file
49
api/businesses/saveBrandColor.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Save Business Brand Color
|
||||
* POST: { BusinessID, BrandColor, BrandColorLight? }
|
||||
*/
|
||||
|
||||
function normalizeHex(string $raw): string {
|
||||
$c = trim($raw);
|
||||
if ($c === '') return '';
|
||||
if ($c[0] === '#') $c = substr($c, 1);
|
||||
if (strlen($c) !== 6 || !preg_match('/^[0-9A-Fa-f]{6}$/', $c)) {
|
||||
apiAbort(['OK' => 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()]);
|
||||
}
|
||||
35
api/businesses/saveOrderTypes.php
Normal file
35
api/businesses/saveOrderTypes.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Save Business Order Types
|
||||
* POST: { BusinessID, OrderTypes: "1,2" }
|
||||
* 1=Dine-In, 2=Takeaway, 3=Delivery
|
||||
*/
|
||||
|
||||
try {
|
||||
$data = readJsonBody();
|
||||
if (empty($data)) {
|
||||
apiAbort(['OK' => 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()]);
|
||||
}
|
||||
30
api/businesses/setHiring.php
Normal file
30
api/businesses/setHiring.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Set Business Hiring Status
|
||||
* POST: { BusinessID, IsHiring: bool }
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$businessId = (int) ($data['BusinessID'] ?? 0);
|
||||
|
||||
if ($businessId <= 0) {
|
||||
apiAbort(['OK' => 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()]);
|
||||
}
|
||||
85
api/businesses/update.php
Normal file
85
api/businesses/update.php
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Update Business Info
|
||||
* POST: { BusinessID, Name, Phone, TaxRatePercent?, TaxRate?, Line1, City, State, Zip }
|
||||
*/
|
||||
|
||||
try {
|
||||
$data = readJsonBody();
|
||||
if (empty($data)) {
|
||||
apiAbort(['OK' => 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()]);
|
||||
}
|
||||
50
api/businesses/updateHours.php
Normal file
50
api/businesses/updateHours.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Update Business Hours
|
||||
* POST: { BusinessID, Hours: [{ dayId, open, close }, ...] }
|
||||
* Days not in the array are considered closed.
|
||||
*/
|
||||
|
||||
try {
|
||||
$data = readJsonBody();
|
||||
if (empty($data)) {
|
||||
apiAbort(['OK' => 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()]);
|
||||
}
|
||||
72
api/businesses/updateTabs.php
Normal file
72
api/businesses/updateTabs.php
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Update Business Tab/Session Settings
|
||||
* POST: { BusinessID, SessionEnabled, SessionLockMinutes, SessionPaymentStrategy,
|
||||
* TabMinAuthAmount, TabDefaultAuthAmount, TabMaxAuthAmount,
|
||||
* TabAutoIncreaseThreshold, TabMaxMembers, TabApprovalRequired }
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
if (empty($data)) {
|
||||
apiAbort(['OK' => 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()]);
|
||||
}
|
||||
30
api/chat/closeChat.php
Normal file
30
api/chat/closeChat.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Close/complete a chat task
|
||||
* POST: { TaskID: int }
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$taskID = (int) ($data['TaskID'] ?? 0);
|
||||
|
||||
if ($taskID <= 0) {
|
||||
apiAbort(['OK' => 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()]);
|
||||
}
|
||||
61
api/chat/getActiveChat.php
Normal file
61
api/chat/getActiveChat.php
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Check for an active (uncompleted) chat task at a service point
|
||||
* POST: { BusinessID, ServicePointID }
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$businessID = (int) ($data['BusinessID'] ?? 0);
|
||||
$servicePointID = (int) ($data['ServicePointID'] ?? 0);
|
||||
|
||||
if ($businessID <= 0) {
|
||||
apiAbort(['OK' => 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()]);
|
||||
}
|
||||
74
api/chat/getMessages.php
Normal file
74
api/chat/getMessages.php
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Get chat messages for a task
|
||||
* POST: { TaskID, AfterMessageID? }
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$taskID = (int) ($data['TaskID'] ?? 0);
|
||||
$afterMessageID = (int) ($data['AfterMessageID'] ?? 0);
|
||||
|
||||
if ($taskID <= 0) {
|
||||
apiAbort(['OK' => 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()]);
|
||||
}
|
||||
36
api/chat/markRead.php
Normal file
36
api/chat/markRead.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Mark messages as read
|
||||
* POST: { TaskID, ReaderType: "customer"|"worker" }
|
||||
* Marks messages from the OTHER party as read
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$taskID = (int) ($data['TaskID'] ?? 0);
|
||||
$readerType = strtolower(trim($data['ReaderType'] ?? ''));
|
||||
|
||||
if ($taskID <= 0) {
|
||||
apiAbort(['OK' => 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()]);
|
||||
}
|
||||
63
api/chat/sendMessage.php
Normal file
63
api/chat/sendMessage.php
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Send a chat message
|
||||
* POST: { TaskID, Message, SenderType?, UserID? }
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$taskID = (int) ($data['TaskID'] ?? 0);
|
||||
$message = trim($data['Message'] ?? '');
|
||||
$senderType = strtolower(trim($data['SenderType'] ?? 'customer'));
|
||||
$userID = (int) ($data['UserID'] ?? 0);
|
||||
|
||||
global $userId;
|
||||
if ($userID <= 0) $userID = $userId;
|
||||
|
||||
if ($taskID <= 0) {
|
||||
apiAbort(['OK' => 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()]);
|
||||
}
|
||||
100
api/config/stripe.php
Normal file
100
api/config/stripe.php
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<?php
|
||||
/**
|
||||
* Stripe Configuration
|
||||
*
|
||||
* Returns Stripe API keys and fee settings based on environment.
|
||||
*/
|
||||
|
||||
function getStripeConfig(): array {
|
||||
$mode = 'test';
|
||||
|
||||
$testSecretKey = 'sk_test_LfbmDduJxTwbVZmvcByYmirw';
|
||||
$testPublishableKey = 'pk_test_sPBNzSyJ9HcEPJGC7dSo8NqN';
|
||||
$liveSecretKey = 'sk_live_REPLACE_ME';
|
||||
$livePublishableKey = 'pk_live_REPLACE_ME';
|
||||
|
||||
$testWebhookSecret = 'whsec_TJlxt9GPoUWeObmiWbjy8X5fChjQbJHp';
|
||||
$liveWebhookSecret = 'whsec_8t6s9Lz0S5M1SYcEYvZ73qFP4zmtlG6h';
|
||||
|
||||
if ($mode === 'test') {
|
||||
return [
|
||||
'secretKey' => $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 ?: '';
|
||||
}
|
||||
103
api/grants/_grantUtils.php
Normal file
103
api/grants/_grantUtils.php
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<?php
|
||||
/**
|
||||
* Grant utility functions for SP-SM enforcement.
|
||||
* Include this file where grant time/eligibility checks are needed.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if a grant's time policy is currently active.
|
||||
*/
|
||||
function isGrantTimeActive(string $timePolicyType, $timePolicyData = ''): bool {
|
||||
if ($timePolicyType === 'always') return true;
|
||||
|
||||
$policy = [];
|
||||
if (is_string($timePolicyData) && trim($timePolicyData) !== '') {
|
||||
$policy = json_decode($timePolicyData, true);
|
||||
if (!is_array($policy)) return false;
|
||||
} elseif (is_array($timePolicyData)) {
|
||||
$policy = $timePolicyData;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
$now = new DateTime('now', new DateTimeZone('UTC'));
|
||||
|
||||
switch ($timePolicyType) {
|
||||
case 'schedule':
|
||||
// policy: { days: [1,2,3,4,5], startTime: "09:00", endTime: "17:00" }
|
||||
// days: 1=Sunday, 2=Monday, ... 7=Saturday (CF dayOfWeek convention)
|
||||
if (!isset($policy['days']) || !is_array($policy['days'])) return false;
|
||||
// PHP: Sunday=0, CF: Sunday=1. Convert PHP dow to CF convention.
|
||||
$todayDow = (int) $now->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;
|
||||
}
|
||||
}
|
||||
78
api/grants/accept.php
Normal file
78
api/grants/accept.php
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
require_once __DIR__ . '/_grantUtils.php';
|
||||
|
||||
global $userId;
|
||||
|
||||
$data = readJsonBody();
|
||||
$grantID = (int) ($data['GrantID'] ?? 0);
|
||||
$inviteToken = trim($data['InviteToken'] ?? '');
|
||||
|
||||
if ($grantID <= 0 && strlen($inviteToken) === 0) {
|
||||
apiAbort(['OK' => 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.',
|
||||
]);
|
||||
124
api/grants/create.php
Normal file
124
api/grants/create.php
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
require_once __DIR__ . '/_grantUtils.php';
|
||||
|
||||
global $userId;
|
||||
|
||||
$data = readJsonBody();
|
||||
$ownerBusinessID = (int) ($data['OwnerBusinessID'] ?? 0);
|
||||
$guestBusinessID = (int) ($data['GuestBusinessID'] ?? 0);
|
||||
$servicePointID = (int) ($data['ServicePointID'] ?? 0);
|
||||
$economicsType = trim($data['EconomicsType'] ?? 'none');
|
||||
$economicsValue = (float) ($data['EconomicsValue'] ?? 0);
|
||||
$eligibilityScope = trim($data['EligibilityScope'] ?? 'public');
|
||||
$timePolicyType = trim($data['TimePolicyType'] ?? 'always');
|
||||
$timePolicyData = $data['TimePolicyData'] ?? '';
|
||||
|
||||
if ($ownerBusinessID <= 0 || $guestBusinessID <= 0 || $servicePointID <= 0) {
|
||||
apiAbort(['OK' => 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.',
|
||||
]);
|
||||
56
api/grants/decline.php
Normal file
56
api/grants/decline.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
require_once __DIR__ . '/_grantUtils.php';
|
||||
|
||||
global $userId;
|
||||
|
||||
$data = readJsonBody();
|
||||
$grantID = (int) ($data['GrantID'] ?? 0);
|
||||
|
||||
if ($grantID <= 0) {
|
||||
apiAbort(['OK' => 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.',
|
||||
]);
|
||||
96
api/grants/get.php
Normal file
96
api/grants/get.php
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
global $userId;
|
||||
|
||||
$data = readJsonBody();
|
||||
$grantID = (int) ($data['GrantID'] ?? 0);
|
||||
|
||||
if ($grantID <= 0) {
|
||||
apiAbort(['OK' => 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,
|
||||
]);
|
||||
91
api/grants/list.php
Normal file
91
api/grants/list.php
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
global $userId, $businessId;
|
||||
|
||||
$data = readJsonBody();
|
||||
$bizID = (int) ($data['BusinessID'] ?? 0);
|
||||
$role = strtolower(trim($data['Role'] ?? 'owner'));
|
||||
$statusFilter = isset($data['StatusFilter']) ? (int) $data['StatusFilter'] : -1;
|
||||
|
||||
if ($bizID <= 0) $bizID = $businessId;
|
||||
if ($bizID <= 0) {
|
||||
apiAbort(['OK' => 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,
|
||||
]);
|
||||
62
api/grants/revoke.php
Normal file
62
api/grants/revoke.php
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
require_once __DIR__ . '/_grantUtils.php';
|
||||
|
||||
global $userId;
|
||||
|
||||
$data = readJsonBody();
|
||||
$grantID = (int) ($data['GrantID'] ?? 0);
|
||||
|
||||
if ($grantID <= 0) {
|
||||
apiAbort(['OK' => 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.',
|
||||
]);
|
||||
45
api/grants/searchBusiness.php
Normal file
45
api/grants/searchBusiness.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
$data = readJsonBody();
|
||||
$query = trim($data['Query'] ?? '');
|
||||
$excludeBusinessID = (int) ($data['ExcludeBusinessID'] ?? 0);
|
||||
|
||||
if (strlen($query) < 2) {
|
||||
apiAbort(['OK' => 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,
|
||||
]);
|
||||
140
api/grants/update.php
Normal file
140
api/grants/update.php
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
require_once __DIR__ . '/_grantUtils.php';
|
||||
|
||||
global $userId;
|
||||
|
||||
$data = readJsonBody();
|
||||
$grantID = (int) ($data['GrantID'] ?? 0);
|
||||
|
||||
if ($grantID <= 0) {
|
||||
apiAbort(['OK' => 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.',
|
||||
]);
|
||||
484
api/helpers.php
Normal file
484
api/helpers.php
Normal file
|
|
@ -0,0 +1,484 @@
|
|||
<?php
|
||||
/**
|
||||
* Payfrit API Helpers
|
||||
*
|
||||
* Core functions shared by all API endpoints.
|
||||
* Include this at the top of every endpoint file.
|
||||
*/
|
||||
|
||||
// Timezone: everything is UTC
|
||||
date_default_timezone_set('UTC');
|
||||
|
||||
// No-cache + CORS headers on every response
|
||||
header('Cache-Control: no-store');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Headers: Content-Type, X-User-Token, X-Business-ID');
|
||||
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||
|
||||
// Handle OPTIONS preflight
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(204);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// DATABASE
|
||||
// ============================================
|
||||
|
||||
function getDb(): PDO {
|
||||
static $pdo = null;
|
||||
if ($pdo !== null) return $pdo;
|
||||
|
||||
// Auto-detect environment by hostname
|
||||
$hostname = gethostname();
|
||||
$isDev = ($hostname !== 'biz');
|
||||
|
||||
$host = '10.10.0.1';
|
||||
$dbname = $isDev ? 'payfrit_dev' : 'payfrit';
|
||||
$user = 'payfrit_app';
|
||||
$pass = $isDev ? 'Bv9#hLs4Wq@zK8nR' : 'Xm7@wT5jY';
|
||||
|
||||
$pdo = new PDO(
|
||||
"mysql:host=$host;dbname=$dbname;charset=utf8mb4",
|
||||
$user,
|
||||
$pass,
|
||||
[
|
||||
PDO::ATTR_ERRMODE => 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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
40
api/menu/clearAllData.php
Normal file
40
api/menu/clearAllData.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Clear ALL menu data (all businesses)
|
||||
*
|
||||
* POST body: { "confirm": "NUKE_EVERYTHING" }
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$confirm = $data['confirm'] ?? '';
|
||||
|
||||
if ($confirm !== 'NUKE_EVERYTHING') {
|
||||
jsonResponse(['OK' => 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' => '']);
|
||||
}
|
||||
61
api/menu/clearBusinessData.php
Normal file
61
api/menu/clearBusinessData.php
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Clear menu data for a specific business
|
||||
*
|
||||
* POST body: { "BusinessID": 37, "confirm": "DELETE_ALL_DATA" }
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$businessID = (int) ($data['BusinessID'] ?? 0);
|
||||
$confirm = $data['confirm'] ?? '';
|
||||
|
||||
if ($businessID === 0) {
|
||||
jsonResponse(['OK' => 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()]);
|
||||
}
|
||||
43
api/menu/clearOrders.php
Normal file
43
api/menu/clearOrders.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Clear ALL orders, line items, tasks, and non-business addresses
|
||||
*
|
||||
* POST body: { "confirm": "NUKE_ORDERS" }
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$confirm = $data['confirm'] ?? '';
|
||||
|
||||
if ($confirm !== 'NUKE_ORDERS') {
|
||||
jsonResponse(['OK' => 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' => '']);
|
||||
}
|
||||
73
api/menu/debug.php
Normal file
73
api/menu/debug.php
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Debug endpoint — shows item/category stats across businesses
|
||||
*/
|
||||
|
||||
try {
|
||||
$response = [];
|
||||
|
||||
// Get all businesses with items
|
||||
$bizRows = queryTimed("
|
||||
SELECT BusinessID, COUNT(*) as ItemCount
|
||||
FROM Items
|
||||
WHERE BusinessID > 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' => '']);
|
||||
}
|
||||
400
api/menu/getForBuilder.php
Normal file
400
api/menu/getForBuilder.php
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Get Menu for Builder
|
||||
* Returns categories and items in structured format for the menu builder UI
|
||||
*/
|
||||
|
||||
/**
|
||||
* Recursively build nested options tree from flat rows.
|
||||
*/
|
||||
function buildOptionsTree(array $allOptions, int $parentId): array {
|
||||
$result = [];
|
||||
foreach ($allOptions as $opt) {
|
||||
if ((int) $opt['ParentItemID'] !== $parentId) continue;
|
||||
|
||||
$children = buildOptionsTree($allOptions, (int) $opt['ItemID']);
|
||||
$result[] = [
|
||||
'id' => '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' => '']);
|
||||
}
|
||||
489
api/menu/items.php
Normal file
489
api/menu/items.php
Normal file
|
|
@ -0,0 +1,489 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Get Menu Items for Customer Apps
|
||||
*
|
||||
* POST body:
|
||||
* {
|
||||
* "BusinessID": 37,
|
||||
* "OrderTypeID": 0, // optional: 1=Dine-In, 2=Takeaway, 3=Delivery
|
||||
* "MenuID": 0 // optional: filter to specific menu
|
||||
* }
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$businessID = (int) ($data['BusinessID'] ?? 0);
|
||||
$orderTypeID = (int) ($data['OrderTypeID'] ?? 0);
|
||||
$requestedMenuID = (int) ($data['MenuID'] ?? 0);
|
||||
|
||||
if ($businessID <= 0) {
|
||||
apiAbort(['OK' => 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(),
|
||||
]);
|
||||
}
|
||||
51
api/menu/listCategories.php
Normal file
51
api/menu/listCategories.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* List Categories for a Business
|
||||
*
|
||||
* POST body: { "BusinessID": 37 }
|
||||
* Returns all categories with their schedule settings.
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$businessID = (int) ($data['BusinessID'] ?? 0);
|
||||
|
||||
if ($businessID <= 0) {
|
||||
apiAbort(['OK' => 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' => '']);
|
||||
}
|
||||
181
api/menu/menus.php
Normal file
181
api/menu/menus.php
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Menu CRUD API
|
||||
*
|
||||
* Actions: list, get, save, delete, reorder, setDefault
|
||||
* POST body: { "BusinessID": 37, "action": "list", ... }
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$businessID = (int) ($data['BusinessID'] ?? 0);
|
||||
|
||||
if ($businessID === 0) {
|
||||
apiAbort(['OK' => 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' => '']);
|
||||
}
|
||||
80
api/menu/saveCategory.php
Normal file
80
api/menu/saveCategory.php
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Save/Update Category
|
||||
*
|
||||
* POST body:
|
||||
* {
|
||||
* "CategoryID": 123, // Required for update
|
||||
* "BusinessID": 37, // Required for insert
|
||||
* "Name": "Breakfast",
|
||||
* "SortOrder": 1,
|
||||
* "OrderTypes": "1,2,3",
|
||||
* "ParentCategoryID": 0,
|
||||
* "ScheduleStart": "06:00:00",
|
||||
* "ScheduleEnd": "11:00:00",
|
||||
* "ScheduleDays": "2,3,4,5,6"
|
||||
* }
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
|
||||
$categoryID = (int) ($data['CategoryID'] ?? 0);
|
||||
$businessID = (int) ($data['BusinessID'] ?? 0);
|
||||
$name = isset($data['Name']) ? substr(trim($data['Name']), 0, 30) : '';
|
||||
$sortOrder = (int) ($data['SortOrder'] ?? 0);
|
||||
$orderTypes = isset($data['OrderTypes']) ? trim($data['OrderTypes']) : '1,2,3';
|
||||
$scheduleStart = !empty($data['ScheduleStart']) ? trim($data['ScheduleStart']) : null;
|
||||
$scheduleEnd = !empty($data['ScheduleEnd']) ? trim($data['ScheduleEnd']) : null;
|
||||
$scheduleDays = !empty($data['ScheduleDays']) ? trim($data['ScheduleDays']) : null;
|
||||
$parentCategoryID = (int) ($data['ParentCategoryID'] ?? 0);
|
||||
|
||||
try {
|
||||
// Enforce 2-level max: if the proposed parent is itself a subcategory, reject
|
||||
if ($parentCategoryID > 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' => '']);
|
||||
}
|
||||
258
api/menu/saveFromBuilder.php
Normal file
258
api/menu/saveFromBuilder.php
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Save menu data from the builder UI
|
||||
*
|
||||
* POST body: { "BusinessID": 37, "Menu": { "categories": [...] } }
|
||||
*/
|
||||
|
||||
// Track which templates we've already saved options for
|
||||
$savedTemplates = [];
|
||||
|
||||
/**
|
||||
* Recursively save options/modifiers at any depth.
|
||||
*/
|
||||
function saveOptionsRecursive(array $options, int $parentID, int $businessID): void {
|
||||
$optSortOrder = 0;
|
||||
foreach ($options as $opt) {
|
||||
$optDbId = (int) ($opt['dbId'] ?? 0);
|
||||
$requiresSelection = (!empty($opt['requiresSelection'])) ? 1 : 0;
|
||||
$maxSelections = (int) ($opt['maxSelections'] ?? 0);
|
||||
$isDefault = (!empty($opt['isDefault'])) ? 1 : 0;
|
||||
$optionID = 0;
|
||||
|
||||
if ($optDbId > 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' => '']);
|
||||
}
|
||||
57
api/menu/updateStations.php
Normal file
57
api/menu/updateStations.php
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Update Station Assignments
|
||||
*
|
||||
* POST body:
|
||||
* {
|
||||
* "BusinessID": 37,
|
||||
* "Assignments": [
|
||||
* { "ItemID": 1, "StationID": 5 },
|
||||
* { "ItemID": 2, "StationID": 5 }
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$businessID = (int) ($data['BusinessID'] ?? 0);
|
||||
$assignments = $data['Assignments'] ?? [];
|
||||
|
||||
if ($businessID <= 0) {
|
||||
apiAbort(['OK' => 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(),
|
||||
]);
|
||||
}
|
||||
105
api/menu/uploadHeader.php
Normal file
105
api/menu/uploadHeader.php
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Upload Business Header Image
|
||||
*
|
||||
* Multipart form: BusinessID (int), header (file)
|
||||
* Or X-Business-ID header for BusinessID.
|
||||
*/
|
||||
|
||||
// Get BusinessID from form, then header
|
||||
$bizId = (int) ($_POST['BusinessID'] ?? 0);
|
||||
if ($bizId <= 0) {
|
||||
$bizId = (int) headerValue('X-Business-ID');
|
||||
}
|
||||
if ($bizId <= 0) {
|
||||
apiAbort(['OK' => 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' => '']);
|
||||
}
|
||||
152
api/menu/uploadItemPhoto.php
Normal file
152
api/menu/uploadItemPhoto.php
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Upload Item Photo
|
||||
*
|
||||
* Multipart form: ItemID (int), photo (file)
|
||||
* Creates thumbnail (128px square), medium (400px), and full (1200px max) versions.
|
||||
*/
|
||||
|
||||
$itemId = (int) ($_POST['ItemID'] ?? 0);
|
||||
|
||||
if ($itemId <= 0) {
|
||||
apiAbort(['OK' => 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' => '']);
|
||||
}
|
||||
91
api/orders/_cartPayload.php
Normal file
91
api/orders/_cartPayload.php
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
<?php
|
||||
/**
|
||||
* Shared function to load full cart payload for an order.
|
||||
* Used by setLineItem, setOrderType, getOrCreateCart.
|
||||
*/
|
||||
|
||||
function loadCartPayload(int $OrderID): array {
|
||||
$qOrder = queryOne("
|
||||
SELECT
|
||||
o.ID, o.UUID, o.UserID, o.BusinessID, o.DeliveryMultiplier,
|
||||
o.OrderTypeID, o.DeliveryFee, o.StatusID, o.AddressID, o.PaymentID,
|
||||
o.Remarks, o.AddedOn, o.LastEditedOn, o.SubmittedOn,
|
||||
o.ServicePointID, o.GrantID, o.GrantOwnerBusinessID,
|
||||
o.GrantEconomicsType, o.GrantEconomicsValue, o.TabID,
|
||||
sp.Name AS ServicePointName,
|
||||
COALESCE(b.DeliveryFlatFee, 0) AS BusinessDeliveryFee,
|
||||
COALESCE(b.TaxRate, 0) AS TaxRate,
|
||||
COALESCE(b.PayfritFee, 0.05) AS PayfritFee
|
||||
FROM Orders o
|
||||
LEFT JOIN Businesses b ON b.ID = o.BusinessID
|
||||
LEFT JOIN ServicePoints sp ON sp.ID = o.ServicePointID
|
||||
WHERE o.ID = ?
|
||||
LIMIT 1
|
||||
", [$OrderID]);
|
||||
|
||||
if (!$qOrder) {
|
||||
return ['OK' => 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];
|
||||
}
|
||||
137
api/orders/_createOrderTasks.php
Normal file
137
api/orders/_createOrderTasks.php
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
<?php
|
||||
/**
|
||||
* Shared task creation logic for orders moving to status 3 (Ready).
|
||||
*
|
||||
* Expects these variables in calling scope:
|
||||
* $OrderID (int)
|
||||
* $qOrder (array with BusinessID, ServicePointID, Name keys)
|
||||
* $NewStatusID (int)
|
||||
* $oldStatusID (int)
|
||||
*
|
||||
* Sets:
|
||||
* $taskCreated (bool)
|
||||
* $cashTaskCreated (bool)
|
||||
*/
|
||||
|
||||
$taskCreated = false;
|
||||
$cashTaskCreated = false;
|
||||
|
||||
if ($NewStatusID == 3 && $oldStatusID != 3) {
|
||||
try {
|
||||
// Get order type
|
||||
$qOrderDetails = queryOne(
|
||||
"SELECT OrderTypeID FROM Orders WHERE ID = ?",
|
||||
[$OrderID]
|
||||
);
|
||||
$orderTypeID = $qOrderDetails ? (int) $qOrderDetails['OrderTypeID'] : 1;
|
||||
|
||||
// Map order type to task type name
|
||||
$taskTypeName = '';
|
||||
if ($orderTypeID == 1) $taskTypeName = 'Deliver to Table';
|
||||
elseif ($orderTypeID == 2) $taskTypeName = 'Order Ready for Pickup';
|
||||
elseif ($orderTypeID == 3) $taskTypeName = 'Deliver to Address';
|
||||
|
||||
$taskTypeID = 0;
|
||||
if ($taskTypeName !== '') {
|
||||
$qTaskType = queryOne(
|
||||
"SELECT ID FROM tt_TaskTypes WHERE BusinessID = ? AND Name = ? LIMIT 1",
|
||||
[$qOrder['BusinessID'], $taskTypeName]
|
||||
);
|
||||
$taskTypeID = $qTaskType ? (int) $qTaskType['ID'] : 0;
|
||||
}
|
||||
|
||||
// Prevent duplicate tasks
|
||||
$qExisting = queryOne(
|
||||
"SELECT ID FROM Tasks WHERE OrderID = ? AND TaskTypeID = ? LIMIT 1",
|
||||
[$OrderID, $taskTypeID]
|
||||
);
|
||||
|
||||
if (!$qExisting && $taskTypeID > 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
|
||||
}
|
||||
}
|
||||
38
api/orders/abandonOrder.php
Normal file
38
api/orders/abandonOrder.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Abandon Order (delete cart)
|
||||
* POST: { OrderID: int }
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$OrderID = (int) ($data['OrderID'] ?? 0);
|
||||
|
||||
if ($OrderID <= 0) {
|
||||
apiAbort(['OK' => 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()]);
|
||||
}
|
||||
72
api/orders/checkStatusUpdate.php
Normal file
72
api/orders/checkStatusUpdate.php
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Check for order status changes (polling endpoint)
|
||||
* POST: { OrderID: int, LastKnownStatusID: int }
|
||||
*/
|
||||
|
||||
$payload = ['OK' => 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);
|
||||
68
api/orders/getActiveCart.php
Normal file
68
api/orders/getActiveCart.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Get user's active cart (status=0) if one exists
|
||||
* GET: ?UserID=int&BusinessID=int (optional)
|
||||
*/
|
||||
|
||||
$UserID = (int) ($_GET['UserID'] ?? 0);
|
||||
$BusinessID = (int) ($_GET['BusinessID'] ?? 0);
|
||||
|
||||
if ($UserID <= 0) {
|
||||
jsonResponse(['OK' => 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()]);
|
||||
}
|
||||
124
api/orders/getCart.php
Normal file
124
api/orders/getCart.php
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Get Cart (order details with line items and calculations)
|
||||
* POST: { OrderID: int }
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$OrderID = (int) ($data['OrderID'] ?? 0);
|
||||
|
||||
if ($OrderID <= 0) {
|
||||
apiAbort(['OK' => 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()]);
|
||||
}
|
||||
186
api/orders/getDetail.php
Normal file
186
api/orders/getDetail.php
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Get Order Detail
|
||||
* GET: ?OrderID=123
|
||||
* POST: { OrderID: 123 }
|
||||
*/
|
||||
|
||||
function getStatusText(int $status): string {
|
||||
return match ($status) {
|
||||
0 => '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);
|
||||
139
api/orders/getOrCreateCart.php
Normal file
139
api/orders/getOrCreateCart.php
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
require_once __DIR__ . '/_cartPayload.php';
|
||||
require_once __DIR__ . '/../grants/_grantUtils.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Get or Create Cart
|
||||
* POST: { BusinessID: int, UserID: int, ServicePointID?: int, OrderTypeID?: int }
|
||||
* Always creates a fresh cart.
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$BusinessID = (int) ($data['BusinessID'] ?? 0);
|
||||
$ServicePointID = (int) ($data['ServicePointID'] ?? 0);
|
||||
$OrderTypeID = (int) ($data['OrderTypeID'] ?? 0);
|
||||
$UserID = (int) ($data['UserID'] ?? 0);
|
||||
|
||||
if ($BusinessID <= 0 || $UserID <= 0) {
|
||||
apiAbort(['OK' => 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(),
|
||||
]);
|
||||
}
|
||||
78
api/orders/getPendingForUser.php
Normal file
78
api/orders/getPendingForUser.php
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Get pending orders for a user at a specific business
|
||||
* GET: ?UserID=int&BusinessID=int
|
||||
* Returns orders with status 1-3 (Submitted, Preparing, Ready)
|
||||
*/
|
||||
|
||||
$response = ['OK' => 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);
|
||||
106
api/orders/history.php
Normal file
106
api/orders/history.php
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Order History
|
||||
* GET: ?limit=20&offset=0
|
||||
* Returns completed/submitted orders for the authenticated user
|
||||
*/
|
||||
|
||||
global $userId;
|
||||
|
||||
if ($userId <= 0) {
|
||||
apiAbort(['OK' => 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(),
|
||||
]);
|
||||
}
|
||||
132
api/orders/listForKDS.php
Normal file
132
api/orders/listForKDS.php
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* List Orders for KDS (Kitchen Display System)
|
||||
* POST: { BusinessID: int, ServicePointID?: int, StationID?: int }
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$BusinessID = (int) ($data['BusinessID'] ?? 0);
|
||||
$ServicePointID = (int) ($data['ServicePointID'] ?? 0);
|
||||
$StationID = (int) ($data['StationID'] ?? 0);
|
||||
|
||||
if ($BusinessID <= 0) {
|
||||
apiAbort(['OK' => 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()]);
|
||||
}
|
||||
98
api/orders/markStationDone.php
Normal file
98
api/orders/markStationDone.php
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Mark Station Done
|
||||
* POST: { OrderID: int, StationID: int }
|
||||
* Marks all root line items for a station as done, auto-promotes order status.
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$OrderID = (int) ($data['OrderID'] ?? 0);
|
||||
$StationID = (int) ($data['StationID'] ?? 0);
|
||||
|
||||
if ($OrderID <= 0 || $StationID <= 0) {
|
||||
apiAbort(['OK' => 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()]);
|
||||
}
|
||||
198
api/orders/setLineItem.php
Normal file
198
api/orders/setLineItem.php
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
require_once __DIR__ . '/_cartPayload.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Set Line Item (add/update/remove items in cart)
|
||||
* POST: { OrderID, ItemID, IsSelected, Quantity?, Remark?, ParentOrderLineItemID?, OrderLineItemID?, ForceNew? }
|
||||
*/
|
||||
|
||||
function nextId(string $table, string $idField): int {
|
||||
$q = queryOne("SELECT IFNULL(MAX({$idField}),0) + 1 AS NextID FROM {$table}", []);
|
||||
return (int) $q['NextID'];
|
||||
}
|
||||
|
||||
function attachDefaultChildren(int $OrderID, int $ParentLineItemID, int $ParentItemID): void {
|
||||
// Direct children
|
||||
$qAllKids = queryTimed(
|
||||
"SELECT ID, Price, IsCheckedByDefault FROM Items WHERE ParentItemID = ? AND IsActive = 1 ORDER BY SortOrder, ID",
|
||||
[$ParentItemID]
|
||||
);
|
||||
|
||||
// Template-linked children
|
||||
$qTemplateKids = queryTimed(
|
||||
"SELECT i.ID, i.Price, i.IsCheckedByDefault
|
||||
FROM lt_ItemID_TemplateItemID tl
|
||||
INNER JOIN Items i ON i.ParentItemID = tl.TemplateItemID
|
||||
WHERE tl.ItemID = ? AND i.IsActive = 1
|
||||
ORDER BY i.SortOrder, i.ID",
|
||||
[$ParentItemID]
|
||||
);
|
||||
|
||||
$allKids = array_merge($qAllKids, $qTemplateKids);
|
||||
|
||||
foreach ($allKids as $kid) {
|
||||
if ((int) $kid['IsCheckedByDefault'] === 1) {
|
||||
processDefaultChild($OrderID, $ParentLineItemID, (int) $kid['ID'], (float) $kid['Price']);
|
||||
} else {
|
||||
attachDefaultChildren($OrderID, $ParentLineItemID, (int) $kid['ID']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function processDefaultChild(int $OrderID, int $ParentLineItemID, int $ItemID, float $Price): void {
|
||||
$qExisting = queryOne(
|
||||
"SELECT ID FROM OrderLineItems WHERE OrderID = ? AND ParentOrderLineItemID = ? AND ItemID = ? LIMIT 1",
|
||||
[$OrderID, $ParentLineItemID, $ItemID]
|
||||
);
|
||||
|
||||
if ($qExisting) {
|
||||
queryTimed("UPDATE OrderLineItems SET IsDeleted = 0 WHERE ID = ?", [(int) $qExisting['ID']]);
|
||||
attachDefaultChildren($OrderID, (int) $qExisting['ID'], $ItemID);
|
||||
} else {
|
||||
$NewLIID = nextId('OrderLineItems', 'ID');
|
||||
queryTimed(
|
||||
"INSERT INTO OrderLineItems (ID, ParentOrderLineItemID, OrderID, ItemID, StatusID, Price, Quantity, Remark, IsDeleted, AddedOn)
|
||||
VALUES (?, ?, ?, ?, 0, ?, 1, NULL, 0, NOW())",
|
||||
[$NewLIID, $ParentLineItemID, $OrderID, $ItemID, $Price]
|
||||
);
|
||||
attachDefaultChildren($OrderID, $NewLIID, $ItemID);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Main logic ---
|
||||
|
||||
$data = readJsonBody();
|
||||
$OrderID = (int) ($data['OrderID'] ?? 0);
|
||||
$ParentLineItemID = (int) ($data['ParentOrderLineItemID'] ?? 0);
|
||||
$OrderLineItemID = (int) ($data['OrderLineItemID'] ?? 0);
|
||||
$OriginalItemID = $data['ItemID'] ?? 0;
|
||||
$ItemID = (int) $OriginalItemID;
|
||||
|
||||
// Decode virtual IDs (format: menuItemID * 100000 + realItemID)
|
||||
$WasDecoded = false;
|
||||
if ($ItemID > 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(),
|
||||
]);
|
||||
}
|
||||
61
api/orders/setOrderType.php
Normal file
61
api/orders/setOrderType.php
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
require_once __DIR__ . '/_cartPayload.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Set Order Type (dine-in, takeaway, delivery)
|
||||
* POST: { OrderID: int, OrderTypeID: int, AddressID?: int }
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$OrderID = (int) ($data['OrderID'] ?? 0);
|
||||
$OrderTypeID = (int) ($data['OrderTypeID'] ?? 0);
|
||||
$AddressID = (int) ($data['AddressID'] ?? 0);
|
||||
|
||||
if ($OrderID <= 0) {
|
||||
apiAbort(['OK' => 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()]);
|
||||
}
|
||||
203
api/orders/submit.php
Normal file
203
api/orders/submit.php
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
require_once __DIR__ . '/../grants/_grantUtils.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Submit Order (card payment flow)
|
||||
* POST: { OrderID: int }
|
||||
* Validates cart, checks grant, validates modifier selections, then marks as submitted.
|
||||
*/
|
||||
|
||||
function buildLineItemsGraph(int $OrderID): array {
|
||||
$out = ['items' => [], '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()]);
|
||||
}
|
||||
119
api/orders/submitCash.php
Normal file
119
api/orders/submitCash.php
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Submit Cash Order
|
||||
* POST: { OrderID: int, CashAmount: float, Tip?: float }
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$OrderID = (int) ($data['OrderID'] ?? 0);
|
||||
$CashAmount = (float) ($data['CashAmount'] ?? 0);
|
||||
$Tip = (float) ($data['Tip'] ?? 0);
|
||||
|
||||
if ($OrderID <= 0) {
|
||||
apiAbort(['OK' => 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()]);
|
||||
}
|
||||
48
api/orders/updateStatus.php
Normal file
48
api/orders/updateStatus.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Update Order Status
|
||||
* POST: { OrderID: int, StatusID: int }
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$OrderID = (int) ($data['OrderID'] ?? 0);
|
||||
$NewStatusID = (int) ($data['StatusID'] ?? 0);
|
||||
|
||||
if ($OrderID <= 0 || $NewStatusID <= 0) {
|
||||
apiAbort(['OK' => 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()]);
|
||||
}
|
||||
39
api/portal/addTeamMember.php
Normal file
39
api/portal/addTeamMember.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
$data = readJsonBody();
|
||||
$businessId = (int) ($data['BusinessID'] ?? 0);
|
||||
$userId = (int) ($data['UserID'] ?? 0);
|
||||
$roleId = (int) ($data['RoleID'] ?? 1);
|
||||
if ($roleId < 1 || $roleId > 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()]);
|
||||
35
api/portal/myBusinesses.php
Normal file
35
api/portal/myBusinesses.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
global $userId;
|
||||
|
||||
// Also check request body for UserID
|
||||
$userID = $userId;
|
||||
if ($userID <= 0) {
|
||||
$data = readJsonBody();
|
||||
$userID = (int) ($data['UserID'] ?? 0);
|
||||
}
|
||||
|
||||
if ($userID <= 0) {
|
||||
apiAbort(['OK' => 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),
|
||||
]);
|
||||
15
api/portal/reassign_employees.php
Normal file
15
api/portal/reassign_employees.php
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
$targetBusinessID = 44;
|
||||
|
||||
queryTimed("UPDATE Employees SET BusinessID = ?", [$targetBusinessID]);
|
||||
|
||||
$qCount = queryOne("SELECT COUNT(*) AS cnt FROM Employees WHERE BusinessID = ?", [$targetBusinessID]);
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'MESSAGE' => "All employee records reassigned to BusinessID $targetBusinessID",
|
||||
'COUNT' => (int) $qCount['cnt'],
|
||||
]);
|
||||
64
api/portal/searchUser.php
Normal file
64
api/portal/searchUser.php
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
$data = readJsonBody();
|
||||
$query = trim($data['Query'] ?? '');
|
||||
$businessId = (int) ($data['BusinessID'] ?? 0);
|
||||
|
||||
if (strlen($query) < 3) {
|
||||
apiAbort(['OK' => 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]);
|
||||
}
|
||||
51
api/portal/stats.php
Normal file
51
api/portal/stats.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
$data = readJsonBody();
|
||||
$businessID = (int) ($data['BusinessID'] ?? 0);
|
||||
|
||||
if ($businessID <= 0) {
|
||||
apiAbort(['OK' => 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'],
|
||||
],
|
||||
]);
|
||||
61
api/portal/team.php
Normal file
61
api/portal/team.php
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
$data = readJsonBody();
|
||||
$businessId = (int) ($data['BusinessID'] ?? 0);
|
||||
|
||||
if ($businessId <= 0) {
|
||||
apiAbort(['OK' => 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),
|
||||
]);
|
||||
32
api/presence/heartbeat.php
Normal file
32
api/presence/heartbeat.php
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
runAuth();
|
||||
|
||||
try {
|
||||
$data = readJsonBody();
|
||||
|
||||
$userID = (int) ($data['UserID'] ?? 0);
|
||||
$businessID = (int) ($data['BusinessID'] ?? 0);
|
||||
$servicePointID = (int) ($data['ServicePointID'] ?? 0);
|
||||
|
||||
if ($userID === 0) apiAbort(['OK' => 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()]);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue