Adds a scope-based access control layer to all 81 existing API routes. Sits alongside existing session-cookie auth (does not replace it). What this PR does: - Audits every existing route and documents required scope per route (see documentation/auth/route-scope-audit.md) - Adds classes/auth/scope.php with 10 scope constants and role→scope defaults - Adds classes/auth/scope_middleware.php with requireScope/requireAnyScope/requireRole - Applies require*() calls to all 81 existing routes - Adds ScopeMiddlewareTest (unit, 178 lines) and RouteScopeTest (integration, 212 lines) Coexistence note: This branch's classes/auth/scope.php is a stub that will be replaced by classes/auth/scope_registry.php (from TRU-145 / PR #396) when that PR merges first. The two have compatible APIs. Refs: TRU-149
296 lines
13 KiB
PHP
296 lines
13 KiB
PHP
<?php
|
|
|
|
namespace routes;
|
|
|
|
use classes\response;
|
|
use classes\router;
|
|
use objects\department_time_bookings_entries_o;
|
|
use objects\department_time_bookings_opening_hours_o;
|
|
use objects\department_time_bookings_types_o;
|
|
use objects\departments_o;
|
|
use objects\product_options_o;
|
|
use traits\route_t;
|
|
|
|
use app\auth\Scope;
|
|
use app\auth\ScopeMiddleware;
|
|
|
|
class customerTimeBookingsRoute
|
|
{
|
|
use route_t;
|
|
|
|
private function requirePublicTimeBookingsDepartment(string $parameterName = 'id'): departments_o
|
|
{
|
|
global $response;
|
|
|
|
if (!$this->isParametersSet([$parameterName])) {
|
|
$response->error('Missing ' . $parameterName . ' parameter', 400);
|
|
}
|
|
$this->requireType((int)$this->getParameter($parameterName), $this->type_int());
|
|
$this->requireMinValue((int)$this->getParameter($parameterName), 1);
|
|
$this->requireSameLength($this->getParameter($parameterName), (int)$this->getParameter($parameterName));
|
|
|
|
$department = new departments_o();
|
|
$department->select((int)$this->getParameter($parameterName));
|
|
if (!$department->exists()) {
|
|
$response->error('Department not found', 404);
|
|
}
|
|
|
|
if (!$department->isModuleTimeBookingsEnabled()) {
|
|
$response->error('Department time bookings are not enabled', 404);
|
|
}
|
|
|
|
return $department;
|
|
}
|
|
|
|
public function run(): void
|
|
{
|
|
global /** @var response $response */
|
|
/** @var router $router */
|
|
$router, $response;
|
|
|
|
|
|
/** Guest Time Bookings -> Departments -> GET */
|
|
$this->get('/department/timebookings/departments/public', function () {
|
|
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/department/timebookings/departments/public');
|
|
global $response;
|
|
|
|
$departments = new departments_o();
|
|
$response->success(
|
|
$departments
|
|
->setSearchableFields([
|
|
'id',
|
|
'name',
|
|
'description',
|
|
'visible',
|
|
'archived',
|
|
'longitude',
|
|
'latitude',
|
|
])
|
|
->listObjectsWithPaginationIfSet(
|
|
function ($department): array {
|
|
return [
|
|
'id' => (int)$department['id'],
|
|
'name' => (string)$department['name'],
|
|
'description' => (string)$department['description'],
|
|
'address' => (string)$department['description'],
|
|
'longitude' => (float)$department['longitude'],
|
|
'latitude' => (float)$department['latitude'],
|
|
'order_priority' => (int)$department['order_priority'],
|
|
'time_booking_enabled' => true,
|
|
];
|
|
},
|
|
$departments->forceRestrictFilters([
|
|
'visible' => 1,
|
|
'archived' => 0,
|
|
]),
|
|
[],
|
|
"EXISTS (
|
|
SELECT 1
|
|
FROM department_variables time_booking_variables
|
|
WHERE time_booking_variables.department_id = departments.id
|
|
AND time_booking_variables.variable = 'bookingsystem_time_based_enabled'
|
|
AND time_booking_variables.value = 'true'
|
|
)"
|
|
)
|
|
);
|
|
},
|
|
[
|
|
// No permissions required for this endpoint, as it is for guests
|
|
]
|
|
);
|
|
|
|
/** Guest Time Bookings -> Opening Hours -> GET */
|
|
$this->get('/department/timebookings/opening-hours/public', function () {
|
|
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/department/timebookings/opening-hours/public');
|
|
global $response;
|
|
$this->requirePublicTimeBookingsDepartment();
|
|
|
|
$department_time_bookings_opening_hours = new department_time_bookings_opening_hours_o();
|
|
$department_time_bookings_opening_hours->selectByDepartment(
|
|
(int)self::getParameter('id')
|
|
);
|
|
if (!$department_time_bookings_opening_hours->exists()) {
|
|
$response->error('Department time bookings opening hours not found', 404);
|
|
}
|
|
$result = $department_time_bookings_opening_hours->asArray();
|
|
// Restrict the result to only the opening hours
|
|
$result = array_filter($result, function ($key) {
|
|
return in_array($key, [
|
|
'monday_start',
|
|
'monday_end',
|
|
'tuesday_start',
|
|
'tuesday_end',
|
|
'wednesday_start',
|
|
'wednesday_end',
|
|
'thursday_start',
|
|
'thursday_end',
|
|
'friday_start',
|
|
'friday_end',
|
|
'saturday_start',
|
|
'saturday_end',
|
|
'sunday_start',
|
|
'sunday_end'
|
|
]);
|
|
}, ARRAY_FILTER_USE_KEY);
|
|
$response->success($result);
|
|
},
|
|
[
|
|
// No permissions required for this endpoint, as it is for guests
|
|
]
|
|
);
|
|
|
|
/** Guest Time Bookings -> Types -> GET */
|
|
$this->get('/department/timebookings/types/public', function () {
|
|
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/department/timebookings/types/public');
|
|
global $response;
|
|
$this->requirePublicTimeBookingsDepartment();
|
|
|
|
$department_time_bookings_types = new department_time_bookings_types_o();
|
|
$booking_types_array = $department_time_bookings_types->getFieldsWhere(
|
|
[
|
|
'department' => (int)self::getParameter('id'),
|
|
],
|
|
['id', 'department', 'product', 'name', 'description', 'duration']
|
|
);
|
|
function formatBookingType($booking_type): array
|
|
{
|
|
return [
|
|
'id' => (int)$booking_type['id'],
|
|
'department' => (int)$booking_type['department'],
|
|
'product' => (int)$booking_type['product'],
|
|
'name' => (string)$booking_type['name'],
|
|
'description' => (string)$booking_type['description'],
|
|
'duration' => (int)$booking_type['duration'],
|
|
];
|
|
}
|
|
|
|
$result = array_map('formatBookingType', $booking_types_array);
|
|
if (empty($result)) {
|
|
$response->error('No department time bookings types found', 404);
|
|
}
|
|
$response->success($result);
|
|
},
|
|
[
|
|
// No permissions required for this endpoint, as it is for guests
|
|
]
|
|
);
|
|
/** Guest Time Bookings -> Entries -> GET */
|
|
$this->get('/department/timebookings/entries/public', function () {
|
|
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/department/timebookings/entries/public');
|
|
/**
|
|
* @example Usage of this endpoint:
|
|
* GET /department/timebookings/entries/public?id=1&filters=created_at-date_from:2025-04-01,created_at-date_to:2025-05-30&order=created_at:desc
|
|
* This will return all time booking entries for the department with ID 1, created between 2025-04-01 and 2025-05-30, ordered by created_at in descending order.
|
|
* https://api.truckwash.dk:4433/department/timebookings/entries/public?id=1&filters=created_at-date_from:2025-04-01,created_at-date_to:2025-05-30&order=created_at:desc
|
|
*/
|
|
global $response;
|
|
$department = $this->requirePublicTimeBookingsDepartment();
|
|
|
|
$department_time_bookings_entries = new department_time_bookings_entries_o();
|
|
$booking_entries_array = $department_time_bookings_entries->listObjectsWithPaginationIfSet(
|
|
function ($booking_entry): array {
|
|
$booking_type_duration = (new department_time_bookings_types_o())->getDurationById((int)$booking_entry['type']);
|
|
return [
|
|
'start' => (string)$booking_entry['start'],
|
|
'duration' => (int)$booking_type_duration, // The duration in minutes, defined by the type
|
|
];
|
|
},
|
|
$department_time_bookings_entries->forceRestrictFilters(
|
|
[
|
|
'department' => [
|
|
(int)$department->id
|
|
],
|
|
]
|
|
)
|
|
);
|
|
$response->success($booking_entries_array);
|
|
},
|
|
[
|
|
// No permissions required for this endpoint, as it is for guests
|
|
]
|
|
);
|
|
|
|
/** Guest Time Bookings -> Entries -> Add */
|
|
$this->post('/department/timebookings/entries/public', function () {
|
|
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/department/timebookings/entries/public');
|
|
global $response;
|
|
self::requireParameters(['department', 'type', 'start']);
|
|
$department = $this->requirePublicTimeBookingsDepartment('department');
|
|
|
|
// Get the type
|
|
self::requireType((int)self::getParameter('type'), self::type_int());
|
|
self::requireMinValue((int)self::getParameter('type'), 1);
|
|
self::requireSameLength(self::getParameter('type'), (int)self::getParameter('type'));
|
|
|
|
// Get the start time
|
|
self::requireType((string)self::getParameter('start'), self::type_string());
|
|
self::requireMinLength('start', 1);
|
|
self::requireMaxLength('start', 255);
|
|
self::requireSameLength(self::getParameter('start'), (string)self::getParameter('start'));
|
|
self::requireDateFormat((string)self::getParameter('start'), 'Y-m-d H:i:s');
|
|
|
|
// Check if the type exists
|
|
$department_time_bookings_types = new department_time_bookings_types_o();
|
|
$department_time_bookings_types->select((int)self::getParameter('type'));
|
|
if (!$department_time_bookings_types->exists()) {
|
|
$response->error('Department time bookings type not found', 404);
|
|
}
|
|
// Check if the type belongs to the department
|
|
if ((int)$department_time_bookings_types->department->value() !== (int)$department->id) {
|
|
$response->error('Department time bookings type does not belong to the department', 404);
|
|
}
|
|
// Calculate the end time
|
|
$start_time = \DateTime::createFromFormat('Y-m-d H:i:s', (string)self::getParameter('start'));
|
|
if (!$start_time) {
|
|
$response->error('Invalid start time format', 400);
|
|
}
|
|
$duration = (int)$department_time_bookings_types->duration->value();
|
|
if ($duration <= 0) {
|
|
$response->error('Invalid duration for department time bookings type', 400);
|
|
}
|
|
$end_time = clone $start_time;
|
|
$end_time->modify("+{$duration} minutes");
|
|
|
|
// Check if the addons parameter is set
|
|
$addons = [];
|
|
if (self::isParametersSet(['addons'])) {
|
|
self::requireType((array)self::getParameter('addons'), self::type_array());
|
|
$addons = (array)self::getParameter('addons');
|
|
// Validate each addon
|
|
foreach ( $addons as $addon ) {
|
|
self::requireType((int)$addon, self::type_int());
|
|
self::requireMinValue((int)$addon, 1);
|
|
self::requireSameLength($addon, (int)$addon);
|
|
// Check if the addon is allowed on the primary type
|
|
$product_options = new product_options_o();
|
|
if (!$product_options->isOptionAllowedOnType(
|
|
(int)$addon,
|
|
(int)$department_time_bookings_types->product->value()
|
|
)) {
|
|
$response->error('This product option is not allowed on the primary product type', 400);
|
|
}
|
|
}
|
|
}
|
|
// Add the department time bookings entry
|
|
$department_time_bookings_entries = new department_time_bookings_entries_o();
|
|
$department_time_bookings_entries->add(
|
|
(int)self::getParameter('department'),
|
|
(int)self::getParameter('type'),
|
|
(string)$start_time->format('Y-m-d H:i:s'),
|
|
(string)$end_time->format('Y-m-d H:i:s'),
|
|
(int)(self::isParametersSet(['phone_country_code']) ? self::getParameter('phone_country_code') : 0),
|
|
(int)(self::isParametersSet(['phone']) ? self::getParameter('phone') : 0),
|
|
(self::isParametersSet(['note']) ? (string)self::getParameter('note') : null),
|
|
(self::isParametersSet(['reg']) ? (string)self::getParameter('reg') : null),
|
|
(array)$addons,
|
|
);
|
|
// Return success
|
|
$response->success($department_time_bookings_entries->asArray());
|
|
},
|
|
[
|
|
// No permissions required for this endpoint, as it is for guests
|
|
]
|
|
);
|
|
}
|
|
}
|