Files
api/services/nginx/app/traits/db_object_t.php
T
Jepp9350 48ad281baa Add notifications module with routes, types, and helpers
Introduced a complete notifications module, including classes for managing notifications (`notifications_o`), traits for handling types and routing, and API routes to list, add, and delete notifications. Added input validation, permission handling, and JSON data processing capabilities.
2025-03-10 13:11:44 +01:00

811 lines
30 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');
}
} else {
$order = ['id' => 'ASC']; // Default order
}
// List the objects with pagination
if ($page && $limit) {
return $this->listObjectsWithPagination($page, $limit, $search, $filters, $order, $parseFunction);
}
// If the page and limit are not set, list all objects
return $this->listObjectsWithPagination(1, 1000, $search, $filters, $order, $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)) {
// Check if the filter is even set
if (isset($filters[$field]) && !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
* @throws Exception If the object does not exist in the database, it throws an exception
*/
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();
}
}
}