payfrit-api/api/menu/items.php
John Mizerek 1f81d98c52 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.
2026-03-14 14:26:59 -07:00

489 lines
20 KiB
PHP

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