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:
John Mizerek 2026-03-14 14:26:59 -07:00
commit 1f81d98c52
167 changed files with 17800 additions and 0 deletions

72
api/addresses/add.php Normal file
View 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
View 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
View 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()]);
}

View 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
View 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
View 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
View 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.',
]);

View 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
View 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
View 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
View 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']);

View 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
View 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
View 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
View 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
View 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
View 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
View 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);

View 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']),
]);

View 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,
]);

View 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
View 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,
]);

View 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()]);
}

View 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()]);
}

View 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()]);
}

View 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()]);
}

View 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()]);
}

View 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()]);
}

View 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()]);
}

View 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
View 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
View 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'],
],
]);

View 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
View 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
View 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
View 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()]);
}

View 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
View 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
View 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()]);
}

View 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
View 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()]);
}

View 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()]);
}

View 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()]);
}

View 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
View 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()]);
}

View 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()]);
}

View 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
View 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()]);
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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.',
]);

View 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
View 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
View 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
View 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' => '']);
}

View 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
View 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
View 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
View 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
View 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(),
]);
}

View 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
View 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
View 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' => '']);
}

View 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' => '']);
}

View 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
View 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' => '']);
}

View 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' => '']);
}

View 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];
}

View 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
}
}

View 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()]);
}

View 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);

View 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
View 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
View 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);

View 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(),
]);
}

View 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
View 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
View 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()]);
}

View 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
View 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(),
]);
}

View 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
View 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
View 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()]);
}

View 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()]);
}

View 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()]);

View 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),
]);

View 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
View 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
View 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
View 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),
]);

View 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