Files
api/services/nginx/app/traits/db_object_t.php
T
Jepp9350 491f268108 Add role and permission management with enhanced access control
This update introduces functionalities for managing roles, permissions, and access control across departments. Key additions include methods for filtering, restricting, and handling user permissions, as well as new APIs for assigning/removing permissions to/from roles. Access to resources like orders, bookings, and plate scans is now securely tied to department-specific permissions.
2025-02-27 17:54:18 +01:00

806 lines
29 KiB
PHP

<?php
namespace traits;
use classes\db;
use classes\response;
use Exception;
use objects\bookings_o;
use objects\categories_o;
use objects\cron_o;
use objects\customer_codes_o;
use objects\customer_notes_o;
use objects\customer_vehicles_o;
use objects\department_categories_o;
use objects\departments_o;
use objects\economic_module_orders;
use objects\logs_o;
use objects\order_items_o;
use objects\orders_o;
use objects\plate_scanners_o;
use objects\plate_scans_o;
use objects\product_options_o;
use objects\products_o;
use objects\ratelimit_o;
use objects\tokens_o;
use objects\user_key_value_pairs_o;
use objects\user_price_overrides_o;
use objects\users_o;
trait db_object_t
{
public int $id; // The id of the object in the database
private string $table; // The table of the objects in the database (e.g. users)
private array $searchableFields = []; // The fields to search in the database (e.g. ['name', 'email']). If empty, all fields will be searched
private array $whereClauses = []; // The where clauses to add to the pagination query
public function __construct()
{
$this->structure();
}
/**
* Structure: Define the table and fields of the objects in the database
*/
public function structure(): void
{
// Define the table and fields of the objects in the database
}
public function countRowsWhere(array $fieldsAndValues): int
{
global $db;
$table = $this->table;
$where = [];
foreach ( $fieldsAndValues as $field => $value ) {
$where[] = "$field = '$value'";
}
$where = implode(' AND ', $where);
$sql = "SELECT COUNT(*) AS count FROM $table WHERE $where";
$result = $db->query($sql);
$row = $db->fetch_assoc($result);
return $row['count'];
}
public function getFieldsWhere(array $fieldsAndValues, array $fields): array
{
global $db;
$table = $this->table;
$where = [];
foreach ( $fieldsAndValues as $field => $value ) {
// If the value is null, add a where clause to check if the field is null
if ($value === null) {
$where[] = "$field IS NULL";
} else {
$where[] = "$field = '$value'";
}
}
$where = implode(' AND ', $where);
$sql = "SELECT " . implode(', ', $fields) . " FROM $table WHERE $where";
$result = $db->query($sql);
return $db->fetch_all($result);
}
/**
* Set the searchable fields
* @param array $fields The fields to search in the database (e.g. ['name', 'email'])
* @return users_o|bookings_o|cron_o|customer_codes_o|customer_notes_o|customer_vehicles_o|departments_o|economic_module_orders|logs_o|order_items_o|orders_o|plate_scanners_o|plate_scans_o|products_o|ratelimit_o|tokens_o|user_key_value_pairs_o|user_price_overrides_o|db_object_t
*/
public function setSearchableFields(array $fields): self
{
$this->searchableFields = $fields;
return $this;
}
/**
* Add a where clause to the pagination query
* @param string $field
* @param mixed $value
* @return users_o|bookings_o|categories_o|cron_o|customer_codes_o|customer_notes_o|customer_vehicles_o|department_categories_o|departments_o|economic_module_orders|logs_o|order_items_o|orders_o|plate_scanners_o|plate_scans_o|product_options_o|products_o|ratelimit_o|tokens_o|user_key_value_pairs_o|user_price_overrides_o|db_object_t
*/
public function addWhereClause(string $field, mixed $value): self
{
$this->whereClauses[] = "$field = '$value'";
return $this;
}
public function __toString(): string
{
// Return the object as a string
return json_encode($this->getArray());
}
/**
* Get current object row as an array
* @return array The current object row as an array
* @throws Exception If object not found
*/
public function getArray(): array
{
// Get the object from the database
global $db;
// Check if the id is set, if not throw an exception
if (!isset($this->id)) {
throw new Exception('Object not found');
}
$id = $this->id;
$sql = "SELECT * FROM $this->table WHERE id = $id";
$result = $db->query($sql);
return $db->fetch_assoc($result);
}
/**
* List objects with pagination (if set)
* @param callable|null $parseFunction The function to parse the objects
* @param string|null $forcedFilters The filters to force on the objects (Use the array_to_filters function to convert an array to a string)
* @return array The list of objects in the table
* @throws Exception
*/
public function listObjectsWithPaginationIfSet($parseFunction = null, $forcedFilters = null): array
{
// Link to the listObjectsWithPaginationIfSet function.
return self::db_object_t__listObjectsWithPaginationIfSet($parseFunction, $forcedFilters);
}
/**
* List objects with pagination (if set)
* @note This is separate from listObjectsWithPaginationIfSet, as it is used in the API routes with specific permission checks
* @param callable|null $parseFunction The function to parse the objects
* @param string|null $forcedFilters The filters to force on the objects (Use the array_to_filters function to convert an array to a string)
* @return array
* @throws Exception If the user does not have permission to list the objects
*/
public function db_object_t__listObjectsWithPaginationIfSet($parseFunction = null, $forcedFilters = null): array
{
global $response;
$page = ((int)$response->getRequestParameter('page')) ?? null; // Get the page number
$limit = ((int)$response->getRequestParameter('limit')) ?? null; // Get the number of objects per page
$search = $response->getRequestParameter('search') ?? null; // Get the search query
// If the forced filters are set, use them
if ($forcedFilters) {
$filters = $forcedFilters;
} else {
$filters = $response->getRequestParameter('filters') ?? null; // Get the filters ( Eg. department_id:1,role_id:2 OR department_id:1 )
}
$order = $response->getRequestParameter('order') ?? null; // Get the order ( Eg. name:ASC )
// Make the filters an array
if ($filters) {
$filters = explode(',', $filters);
$temp = [];
foreach ( $filters as $filter ) {
$filter = explode(':', $filter);
// Check if the filter already exists, if it does, make it an array
if (isset($temp[$filter[0]])) {
if (!is_array($temp[$filter[0]])) {
$temp[$filter[0]] = [$temp[$filter[0]]];
}
// Check if the filter already exists within the array with the same key, if it does, skip it
if (in_array($filter[1], $temp[$filter[0]])) {
continue;
}
$temp[$filter[0]][] = $filter[1];
continue;
}
$temp[$filter[0]] = $filter[1];
}
$filters = $temp;
}
// Make the order an array
if ($order) {
$order = explode(':', $order);
$order = [$order[0] => $order[1]];
// Make the order direction uppercase
$order = array_map('strtoupper', $order);
// Check if the order direction is valid
if (!in_array($order[array_key_first($order)], ['ASC', 'DESC'])) {
throw new Exception('Invalid order direction, must be ASC or DESC');
}
}
// List the objects with pagination
if ($page && $limit) {
return $this->listObjectsWithPagination($page, $limit, $search, $filters, $order, $parseFunction);
}
return $this->listObjects($parseFunction);
}
/**
* List objects in the table with pagination
* @param int $page The page number
* @param int $limit The number of objects per page
* @return array The list of objects in the table
* @throws Exception
*/
public function listObjectsWithPagination(int $page, int $limit, ?string $search = null, ?array $filters = null, ?array $order = null, $parseFunction = null): array
{
global /** @var response $response */
/** @var db $db */
$db, $response;
$mysqli = $db->conn;
$offset = ($page - 1) * $limit;
// Fetch table fields to search
$fields = $this->searchableFields;
$fieldTypes = [];
if (empty($fields)) {
$fields = [];
$result = $mysqli->query("SHOW COLUMNS FROM {$this->table}");
while ($row = $result->fetch_assoc()) {
$fields[] = $row['Field'];
$fieldTypes[$row['Field']] = $row['Type'];
}
$result->free();
}
$whereClauses = [];
$params = [];
// Add where clauses from the object, if set. This is done to allow for custom where clauses in routes, while still allowing for pagination
if (!empty($this->whereClauses)) {
$whereClauses = $this->whereClauses;
}
// Search clause
if (!empty($search)) {
// Use prepared statements to prevent SQL injection
$searchClauses = [];
foreach ( $fields as $field ) {
$searchClauses[] = "`$field` LIKE ?";
$params[] = "%$search%";
}
$whereClauses[] = '(' . implode(' OR ', $searchClauses) . ')';
}
// Filters clause
if (!empty($filters)) {
foreach ( $filters as $field => $value ) {
if (in_array($field, $fields)) {
// If the value is an array, add multiple filters
if (is_array($value)) {
$temp = [];
foreach ( $value as $v ) {
// Determine the type of the field
$type = $fieldTypes[$field];
// If the field is an integer, cast the value to an integer
if (str_contains($type, 'int')) {
$temp[] = "`$field` = $v";
} else {
$temp[] = "`$field` = ?";
$params[] = $v;
}
//$temp[] = "`$field` = ?";
//$params[] = $v;
}
$whereClauses[] = '(' . implode(' OR ', $temp) . ')';
continue;
}
$whereClauses[] = "`$field` = ?";
$params[] = $value;
}
}
}
// Check for "deleted_at" column
if (in_array('deleted_at', $fields)) {
$whereClauses[] = "`deleted_at` IS NULL";
}
// Combine WHERE clauses
$whereQuery = !empty($whereClauses) ? 'WHERE ' . implode(' AND ', $whereClauses) : '';
// Order clause
$orderQuery = '';
if (!empty($order)) {
$orderParts = [];
foreach ( $order as $field => $direction ) {
if (in_array($field, $fields)) {
$direction = strtoupper($direction) === 'DESC' ? 'DESC' : 'ASC';
$orderParts[] = "`$field` $direction";
}
}
$orderQuery = !empty($orderParts) ? 'ORDER BY ' . implode(', ', $orderParts) : '';
}
// Pagination clause
$limitQuery = 'LIMIT ? OFFSET ?';
$params[] = $limit;
$params[] = $offset;
// Final query
$sql = "SELECT * FROM {$this->table} $whereQuery $orderQuery $limitQuery";
// Prepare and bind
$stmt = $mysqli->prepare($sql);
if ($stmt === false) {
throw new Exception("Failed to prepare statement: " . $mysqli->error);
}
// Dynamically bind parameters
$types = str_repeat('s', count($params) - 2) . 'ii'; // 's' for strings, 'i' for limit and offset
$stmt->bind_param($types, ...$params);
$stmt->execute();
$result = $stmt->get_result();
$objects = $result->fetch_all(MYSQLI_ASSOC);
$stmt->close();
// Total count query
$countSql = "SELECT COUNT(*) AS count FROM {$this->table} $whereQuery";
$countStmt = $mysqli->prepare($countSql);
if ($countStmt === false) {
throw new Exception("Failed to prepare count statement: " . $mysqli->error);
}
// Reuse parameters (excluding LIMIT and OFFSET)
$countParams = array_slice($params, 0, -2);
$countTypes = substr($types, 0, count($countParams));
if (!empty($countParams)) {
$countStmt->bind_param($countTypes, ...$countParams);
}
$countStmt->execute();
$countResult = $countStmt->get_result();
$total = $countResult->fetch_assoc()['count'];
$countStmt->close();
// Provide pagination metadata
$response->paginate($page, $limit, $total, $search, $filters, $order);
// Parse the objects, if a parse function is provided
if ($parseFunction) {
$objects = array_map($parseFunction, $objects);
}
return $objects;
}
/**
* List ALL objects in the table (THIS INCLUDES DELETED OBJECTS)
* @return array The list of objects in the table
*/
public function listObjects($parseFunction = null): array
{
global /** @var db $db */
$db;
$sql = "SELECT * FROM $this->table";
$result = $db->query($sql);
$objects = $db->fetch_all($result);
// Parse the objects, if a parse function is provided
if ($parseFunction) {
$objects = array_map($parseFunction, $objects);
}
return $objects;
}
/**
* Forcefully add or override filters to the user's request
* @param string $filterString The filters to forcefully override/add on the objects (Eg. 'department_id:1,role_id:2')
* @returns string The updated filters string with the forced filters added
*/
public function forceAddFilters(string $filterString): string
{
global /** @var response $response */
$response;
$filters = $response->getRequestParameter('filters') ?? null; // Get the filters ( Eg. department_id:1,role_id:2 OR department_id:1 )
// If the filters are set, add the forced filters to the filters
if ($filters) {
$filters .= ',' . $filterString;
} else {
$filters = $filterString;
}
return $filters;
}
/**
* Forcefully restrict filters to the user's request
* @param array $fieldsAndValues The filters to forcefully restrict on the objects (Eg. ['department_id' => 1, 'role_id' => [1,2]]) - This would throw an error if the user tries to search for a different department_id or role_id than 1 or 2
* @returns string The updated filters string with the forced filters added (Eg. 'department_id:1,role_id:1,role_id:2')
* @throws Exception If the user tries to search for a different department_id or role_id than 1 or 2
*/
public function forceRestrictFilters(array $fieldsAndValues): string
{
global /** @var response $response */
$response;
$filters = $response->getRequestParameter('filters') ?? null; // Get the filters ( Eg. department_id:1,role_id:2 OR department_id:1 )
// If the filters are set, add the forced filters to the filters
if ($filters) {
$filters = $this->filter_string_to_array($filters);
foreach ( $fieldsAndValues as $field => $value ) {
// If the value is an array, check if the user tries to search for a different value than the ones provided
if (is_array($value)) {
if (!in_array($filters[$field], $value)) {
throw new Exception('Invalid filter: ' . $field . ' - ' . $filters[$field] . '. Permitted values: ' . implode(', ', $value));
}
continue;
}
// Check if the user tries to search for a different value than the one provided
if ($filters[$field] !== $value) {
throw new Exception('Invalid filter: ' . $field . ' - ' . $filters[$field] . '. Permitted value: ' . $value);
}
}
// If any of the forced filters are not set, set them - This is done to prevent the user from searching by omitting the forced filters
foreach ( $fieldsAndValues as $field => $value ) {
if (!isset($filters[$field])) {
$filters[$field] = $value;
}
}
} else {
$filters = $fieldsAndValues;
}
return $this->array_to_filters($filters);
}
/**
* Filter string to array
* @param string $filterString The filter string (Eg. 'department_id:1,role_id:2')
* @return array The array of filters (Eg. ['department_id' => 1, 'role_id' => 2])
*/
public function filter_string_to_array(string $filterString): array
{
$filters = explode(',', $filterString);
$temp = [];
foreach ( $filters as $filter ) {
$filter = explode(':', $filter);
$temp[$filter[0]] = $filter[1];
}
return $temp;
}
/**
* Convert an array of filters to a string
* @param $array array The array of filters
* @return string
* @example ['department_id' => [1,2], 'role_id' => 2, 'id' => 1] => 'department_id:1,department_id:2,role_id:2,id:1'
*/
public function array_to_filters(array $array): string
{
$filter_string = '';
foreach ( $array as $key => $value ) {
// If this is not the first filter, add a comma to separate the filters
if ($filter_string !== '') {
$filter_string .= ',';
}
// If the value is an array, add multiple filters
if (is_array($value)) {
foreach ( $value as $v ) {
$filter_string .= $key . ':' . $v . ',';
}
// Remove the last comma
$filter_string = rtrim($filter_string, ',');
continue;
}
// Add the filter
$filter_string .= $key . ':' . $value;
}
return $filter_string;
}
/**
* Get the total number of objects
* @param string|null $search The search query
* @param array|null $filters The filters ( Eg. department_id:1,role_id:2 OR department_id:1 )
* @return int The total number of objects
*/
public function getTotalObjects(string $search = null, array $filters = null): int
{
global $db;
$mysqli = $db->conn;
// Fetch table fields
$fields = [];
$result = $mysqli->query("SHOW COLUMNS FROM {$this->table}");
while ($row = $result->fetch_assoc()) {
$fields[] = $row['Field'];
}
$result->free();
$whereClauses = [];
$params = [];
// Search clause
if (!empty($search)) {
// Use prepared statements to prevent SQL injection
$searchClauses = [];
foreach ( $fields as $field ) {
$searchClauses[] = "`$field` LIKE ?";
$params[] = "%$search%";
}
$whereClauses[] = '(' . implode(' OR ', $searchClauses) . ')';
}
// Filters clause
if (!empty($filters)) {
foreach ( $filters as $field => $value ) {
if (in_array($field, $fields)) {
$whereClauses[] = "`$field` = ?";
$params[] = $value;
}
}
}
// Combine WHERE clauses
$whereQuery = !empty($whereClauses) ? 'WHERE ' . implode(' AND ', $whereClauses) : '';
// Check for "deleted_at" column
if (in_array('deleted_at', $fields)) {
$whereClauses[] = "`deleted_at` IS NULL";
}
// Final query
$sql = "SELECT COUNT(*) AS count FROM {$this->table} $whereQuery";
// Prepare and bind
$stmt = $mysqli->prepare($sql);
if ($stmt === false) {
throw new Exception("Failed to prepare statement: " . $mysqli->error);
}
// Dynamically bind parameters
$types = str_repeat('s', count($params)); // 's' for strings
$stmt->bind_param($types, ...$params);
$stmt->execute();
$result = $stmt->get_result();
return $result->fetch_assoc()['count'];
}
/**
* Get the table of the objects in the database
* @returns string
*/
public function getTable(): string
{
return $this->table;
}
/**
* Set the table of the objects in the database
* @param string $table The table of the objects in the database
*/
public function setTable(string $table): void
{
// Add the backticks to the table name, to prevent SQL injection and reserved keyword issues.
$table = "`$table`";
$this->table = $table;
}
/**
* Cache object
* @param string $key The key to cache the object
* @param mixed $data The data to cache
*/
public function cache(string $key, mixed $data, $objectId = null): void
{
// If the object id is not set, use the object id
if (!$objectId) {
$objectId = $this->id;
}
// if the data is an array, object, or resource, convert it to a string
if (is_array($data) || is_object($data) || is_resource($data)) {
$data = json_encode($data);
}
// Cache the data
redis->set($this->table . '_' . $objectId . '_' . $key, $data);
}
/**
* Get cached object
* @param string $key The key to get the cached object
* @return mixed The cached object
*/
public function getCached(string $key, $objectId = null): mixed
{
// If the object id is not set, use the object id
if (!$objectId) {
$objectId = $this->id;
}
// Get the cached data
$data = redis->get($this->table . '_' . $objectId . '_' . $key) ?? null;
// if the data is a JSON string, convert it to an array
if (is_string($data) && json_decode($data)) {
$data = json_decode($data);
}
return $data;
}
/**
* Delete cached object
* @param string $key The key to delete the cached object
*/
public function deleteCached(string $key, $objectId = null): void
{
// If the object id is not set, use the object id
if (!$objectId) {
$objectId = $this->id;
}
// Delete the cached data
redis->delete($this->table . '_' . $objectId . '_' . $key);
}
/**
* Delete the object from the database, or if the deleted_at column exists, soft delete the object.
* @throws Exception If the object is not selected, it throws an exception
*/
public function delete(): void
{
self::requireSelected();
// Check if the deleted_at column exists
if (self::columnsExist(['deleted_at'])) {
// Soft delete the object
self::update(['deleted_at' => date('Y-m-d H:i:s')]);
} else {
// Delete the object from the database
self::delete_object($this->table, $this->id);
}
// Trigger the object changed event
self::objectChanged();
}
/**
* Require selected object.
* Will throw an exception if object is not selected.
*/
public function requireSelected(): void
{
if (!$this->id) {
throw new Exception('Object not selected or does not exist in ' . $this->table);
}
}
/**
* Columns exist
* Check if all the columns in the array exist in the table
* @param array $columns The columns to check if they exist in the table E.g. ['name', 'email']
* @return bool True if all the columns exist in the table, false otherwise
*/
public function columnsExist(array $columns): bool
{
global $db;
$table = $this->table;
$result = $db->query("SHOW COLUMNS FROM $table");
$fields = $db->fetch_all($result);
$fields = array_column($fields, 'Field');
return count(array_intersect($columns, $fields)) === count($columns);
}
/**
* Update object
* @param array $data The data to update the object with (E.g. ['name' => 'John', 'email' => 'email@example.com'])
* @throws Exception If the object is not selected, it throws an exception
*/
public function update(array $data): void
{
self::requireSelected();
// Update the object in the database
global $db;
$set = [];
foreach ( $data as $key => $value ) {
$value = $db->escape_string($value);
$set[] = "$key = '$value'";
}
$set = implode(', ', $set);
$sql = "UPDATE $this->table SET $set WHERE id = $this->id";
$db->query($sql);
// Trigger the object changed event
self::objectChanged();
}
/**
* Object changed
* This function should be called after an object property is changed
* It will trigger an event to notify the object has changed
* @return void
*/
abstract public function objectChanged(): void;
/**
* Delete object by table and id (Permanently, no soft delete)
* @param string $table The table of the object in the database
* @param int $id The id of the object in the database
*/
public static function delete_object(string $table, int $id): void
{
global $db;
$sql = "DELETE FROM $table WHERE id = $id";
$db->query($sql);
}
/**
* Delete object permanently
* @throws Exception If the object is not selected, it throws an exception
*/
public function deletePermanently(): void
{
self::requireSelected();
// Delete the object from the database
self::delete_object($this->table, $this->id);
// Trigger the object changed event
self::objectChanged();
}
/**
* Get all objects fields
* @param array $fields The fields to get from the objects (Eg. ['name', 'description'])
* @return array The list of fields from the objects (Eg. [['name' => 'John', 'description' => 'Doe'], ['name' => 'Jane', 'description' => 'Doe']])
*/
public function getFields(array $fields): array
{
global $db;
$sql = "SELECT " . implode(', ', $fields) . " FROM $this->table";
$result = $db->query($sql);
return $db->fetch_all($result);
}
/**
* Select an object by id
* @param int $id The id of the object in the database
* @returns $this
*/
public function select(int $id): self
{
// Check if the object exists in the database
$this->id = $id;
$this->getObjectProperties();
return $this;
}
/**
* Add an object to the database, using the data provided
* @param array $data The data to add the object with (E.g. ['name' => 'John', 'email' => 'email@example.com'])
* @return int The id of the new object
* @throws Exception If the creation of the object fails, it throws an exception
*/
public function add_object(array $data): int
{
global /** @var db $db */
$db;
try {
$columns = implode(', ', array_keys($data));
$values = implode("', '", array_values($data));
$sql = "INSERT INTO $this->table ($columns) VALUES ('$values')";
$db->query($sql);
return $db->insert_id();
} catch (Exception $e) {
throw new Exception($e->getMessage());
}
}
/**
* Does this object exist in the database?
* @return bool True if the object exists in the database, false otherwise
*/
public function exists(): bool
{
// Check if the id is set
if (!isset($this->id)) {
return false;
}
// Check if the object exists in the database
global $db;
$id = $this->id;
$sql = "SELECT * FROM $this->table WHERE id = $id";
$result = $db->query($sql);
// Check if the object exists, and if there's a deleted_at column, check if the object is not deleted
return $db->num_rows($result) > 0 && (!$this->columnsExist(['deleted_at']) || $db->fetch_assoc($result)['deleted_at'] === null);
}
/**
* Restore the object from the database, if the deleted_at column exists.
* @throws Exception If the object is not selected, it throws an exception
*/
public function restore(): void
{
self::requireSelected();
// Check if the deleted_at column exists
if (self::columnsExist(['deleted_at'])) {
// Restore the object
global $db;
$id = $this->id;
$sql = "UPDATE $this->table SET deleted_at = NULL WHERE id = $id";
$db->query($sql);
// Trigger the object changed event
self::objectChanged();
}
}
}