From 48ad281baa3e9fd9b845fa8c512764fedec4e40b Mon Sep 17 00:00:00 2001 From: Jepp9350 <2jepp9350@gmail.com> Date: Mon, 10 Mar 2025 13:11:44 +0100 Subject: [PATCH] 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. --- .../helpers/notification_type_t.php | 158 ++++++++++++++++ .../notifications_type_new_booking_c.php | 25 +++ .../nginx/app/objects/notifications_o.php | 113 +++++++++++ .../nginx/app/routes/notificationsRoute.php | 178 ++++++++++++++++++ services/nginx/app/traits/db_object_t.php | 5 +- services/nginx/app/traits/route_t.php | 41 ++++ 6 files changed, 519 insertions(+), 1 deletion(-) create mode 100644 services/nginx/app/modules/notifications/helpers/notification_type_t.php create mode 100644 services/nginx/app/modules/notifications/types/notifications_type_new_booking_c.php create mode 100644 services/nginx/app/objects/notifications_o.php create mode 100644 services/nginx/app/routes/notificationsRoute.php diff --git a/services/nginx/app/modules/notifications/helpers/notification_type_t.php b/services/nginx/app/modules/notifications/helpers/notification_type_t.php new file mode 100644 index 00000000..6da3c31b --- /dev/null +++ b/services/nginx/app/modules/notifications/helpers/notification_type_t.php @@ -0,0 +1,158 @@ +type)) { + throw new Exception('The type is not set'); + } + if (empty($this->name)) { + throw new Exception('The name is not set'); + } + if (empty($this->description)) { + throw new Exception('The description is not set'); + } + } + + /** + * Set the type of the notification + * @param string $type + * @throws Exception If the type is empty + * @throws Exception If the type is not a string + * @throws Exception If the type is not a valid format (e.g. "TYPE_NAME") + */ + public function set_type(string $type): void + { + if (empty($type)) { + throw new Exception('The type is empty'); + } + if (!preg_match('/^[A-Z_]+$/', $type)) { + throw new Exception('The type is not a valid format (e.g. "TYPE_NAME")'); + } + $this->type = $type; + } + + /** + * Set the name of the notification type + * @param string $name + * @throws Exception If the name is empty + */ + public function set_name(string $name): void + { + if (empty($name)) { + throw new Exception('The name is empty'); + } + $this->name = $name; + } + + /** + * Set the description of the notification type + * @param string $description + * @throws Exception If the description is empty + */ + public function set_description(string $description): void + { + if (empty($description)) { + throw new Exception('The description is empty'); + } + $this->description = $description; + } + + /** + * Get the notification type as an array + * @return array + */ + public function as_array(): array + { + return [ + 'type' => $this->type, + 'name' => $this->name, + 'description' => $this->description, + 'data' => self::get_data(), + ]; + } + + /** + * Get the data of the notification type + * @notation This takes all data_* properties and returns them as an array + * @return array + */ + public function get_data(): array + { + // Get all the properties of the class + $properties = get_object_vars($this); + // Filter the properties to only include those that start with "data_" + $data_properties = array_filter($properties, function ($key) { + return str_starts_with($key, 'data_'); + }, ARRAY_FILTER_USE_KEY); + // Remove the "data_" prefix from the keys + // Return the data properties as an array + return array_combine( + array_map(function ($key) { + return substr($key, 5); + }, array_keys($data_properties)), + array_values($data_properties) + ); + } + + /** + * Get the notification type as an object + * @return object + */ + public function as_object(): object + { + return (object)[ + 'type' => $this->type, + 'name' => $this->name, + 'description' => $this->description, + 'data' => self::get_data(), + ]; + } + +} \ No newline at end of file diff --git a/services/nginx/app/modules/notifications/types/notifications_type_new_booking_c.php b/services/nginx/app/modules/notifications/types/notifications_type_new_booking_c.php new file mode 100644 index 00000000..997d1f2c --- /dev/null +++ b/services/nginx/app/modules/notifications/types/notifications_type_new_booking_c.php @@ -0,0 +1,25 @@ +setTable('notifications'); + } + + /** + * Add a notification object, and set this object to the new object + * @param string $type The notification type id + * @param int $user_id The user id + * @param array $data The notification data + * @return void + * @throws Exception If the object was not created successfully + */ + public function add(string $type, int $user_id, array $data): void + { + global /** @var db $db */ + $db; + // Sanitize the input + $type = $db->escape_string($type); + $user_id = (int)$user_id; + // JSON encode the data + $encoded_data = $db->escape_string(json_encode($data)); + // Add the object + $tmp_id = self::add_object([ + 'type' => $type, + 'user_id' => $user_id, + 'data' => $encoded_data, + ]); + if (!$tmp_id) { + throw new Exception('The object was not created successfully.'); + } + $this->id = $tmp_id; + self::getObjectProperties(); + self::objectChanged(); + } + + /** + * Get the object properties + * @return void + */ + public function getObjectProperties(): void + { + $this->type = new object_property($this->table, $this->id, 'type', 'string', false); + $this->user_id = new object_property($this->table, $this->id, 'user_id', 'int', false); + $this->data = new object_property($this->table, $this->id, 'data', 'string', false); + $this->created_at = new object_property($this->table, $this->id, 'created_at', 'datetime', false); + } + + public function objectChanged(): void + { + //TODO: Add cache invalidation + } + + /** + * Get the object as an array + * @return array The object as an array + */ + public function asArray(): array + { + return [ + 'id' => (int)$this->id, + 'type' => (string)$this->type->value(), + 'user_id' => (int)$this->user_id->value(), + 'data' => self::decodeData((string)$this->data->value()), + 'created_at' => (string)$this->created_at->value(), + ]; + } + + /** + * Decode the data + * @param string $data The data JSON encoded + * @return array The data + */ + public static function decodeData(string $data): array + { + return json_decode($data, true); + } +} \ No newline at end of file diff --git a/services/nginx/app/routes/notificationsRoute.php b/services/nginx/app/routes/notificationsRoute.php new file mode 100644 index 00000000..bc044f00 --- /dev/null +++ b/services/nginx/app/routes/notificationsRoute.php @@ -0,0 +1,178 @@ +get('/notifications', function () { + // Require the user to be logged in + global $response; + $this->requirePermission('list_notifications'); + // Check if the user has permission to list all notifications + if (self::hasPermission('list_all_notifications')) { + $this->requirePermission('list_all_notifications'); + } else { + $this->requirePermission('list_own_notifications'); + } + // Get the user object + $user = (new authentication())->get_user(); + // Check if the request was successful + if ($user) { + // Log the incident + (new logs_o())->add('notifications', 'global', 1, $user->id, 'LIST_OWN_NOTIFICATIONS', 'User accessed the list of notifications'); + $notifications = new notifications_o(); + // Return the list of notifications + $response->success( + $notifications + ->setSearchableFields([ + // The fields that can be searched. This would otherwise make it possible to get secret information from the database, simply by searching for it and getting the result count back + 'id', + 'type', + 'user_id', + 'data', + 'created_at', + 'deleted_at', + ]) + ->listObjectsWithPaginationIfSet( + function ($notification) use ($notifications, $user) { + $tmp_notification = [ + 'id' => (int)$notification['id'], + 'type' => (string)$notification['type'], + 'user_id' => (int)$notification['user_id'], + 'data' => $notification['data'] ? $notifications->decodeData($notification['data']) : null, + 'created_at' => (string)$notification['created_at'], + 'deleted_at' => $notification['deleted_at'] ? (string)$notification['deleted_at'] : null, + ]; + return $tmp_notification; + }, + $notifications->forceRestrictFilters( + [ + // This makes sure that the user can only see their own notifications + 'user_id' => $user->id, + ] + ) + ) + ); + } else { + // Log the incident + (new logs_o())->add('notifications', 'global', 1, 0, 'LIST_OWN_NOTIFICATIONS', 'No user found, or invalid session'); + // Return an error + $response->error('Invalid session', 400); + } + }, + [ + 'list_notifications' => 'List notifications, provided the user has either list_all_notifications, or list_own_notifications permission', + 'list_own_notifications' => 'List all notifications for the logged in user', + 'list_all_notifications' => 'List all notifications for all users (superuser only)', + ] + ); + + $this->post('/notifications', function () { + // Require the user to be logged in + global $response; + self::requirePermission('add_notification'); + // Get the user object + $user = (new authentication())->get_user(); + // Check if the request was successful + if ($user) { + // Get the post data + $data = []; + // Check if the required fields are set + self::requireParameters(['type', 'user_id']); + self::requireType((string)self::getParameter('type'), self::TYPE_STRING()); + self::requireType((int)self::getParameter('user_id'), self::TYPE_INT()); + // If the data is set, check if it is an array + if (self::isParametersSet(['data'])) { + self::requireTypeIn( + (array)self::getParameter('data'), + [ + self::TYPE_ARRAY(), + self::TYPE_NULL() + ] + ); + // Check if the data is an array + if (self::getParameter('data') !== null) { + // JSON decode the data + $data = json_decode(self::getParameter('data'), true); + } + } + // Check if the user_id is set + // Add the notification + (new notifications_o())->add( + (string)self::getParameter('type'), + (int)self::getParameter('user_id'), + (array)$data + ); + // Log the incident + (new logs_o())->add('notifications', 'global', 1, $user->id, 'ADD_NOTIFICATION', 'User added a notification'); + // Return the list of departments + $response->success(['message' => 'Notification added successfully']); + } else { + // Log the incident + (new logs_o())->add('notifications', 'global', 1, 0, 'ADD_NOTIFICATION', 'No user found, or invalid session'); + // Return an error + $response->error('Invalid session', 400); + } + }, + [ + 'add_notification' => 'Add a notification' + ] + ); + + + $this->delete('/notifications', function () { + // Require the user to be logged in + global $response; + self::requirePermission('delete_own_notifications'); + // Get the user object + $user = (new authentication())->get_user(); + // Check if the request was successful + if ($user) { + // Require the parameters + self::requireParameters(['id']); + self::requireType((int)self::getParameter('id'), self::TYPE_INT()); + // Get the department category object + $notification = (new notifications_o())->select(self::getParameter('id')); + // Validate the department category object + if (!$notification->exists()) { + // Log the incident + (new logs_o())->add('notifications', 'global', 1, $user->id, 'DELETE_OWN_NOTIFICATIONS', 'User tried to delete a notification that does not exist'); + // Return an error + $response->error('Notification does not exist', 400); + } + // Check if the user is the owner of the notification + if ((int)$notification->user_id->value() !== (int)$user->id) { + // Log the incident + (new logs_o())->add('notifications', 'global', 1, $user->id, 'DELETE_OWN_NOTIFICATIONS', 'User tried to delete a notification that does not belong to them'); + // Return an error + $response->error('You do not have permission to delete this notification', 403); + } + // Delete the department category + $notification->delete(); + // Log the incident + (new logs_o())->add('notifications', 'global', 1, $user->id, 'DELETE_OWN_NOTIFICATIONS', 'User deleted a notification'); + // Return the list of departments + $response->success(['message' => 'Notification deleted successfully']); + } else { + // Log the incident + (new logs_o())->add('notifications', 'global', 1, 0, 'DELETE_OWN_NOTIFICATIONS', 'No user found, or invalid session'); + // Return an error + $response->error('Invalid session', 400); + } + }, + [ + 'delete_own_notifications' => 'Delete a notification' + ] + ); + + } +} \ No newline at end of file diff --git a/services/nginx/app/traits/db_object_t.php b/services/nginx/app/traits/db_object_t.php index 51919151..b4db44ed 100644 --- a/services/nginx/app/traits/db_object_t.php +++ b/services/nginx/app/traits/db_object_t.php @@ -195,12 +195,15 @@ trait db_object_t 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); } - return $this->listObjects($parseFunction); + // If the page and limit are not set, list all objects + return $this->listObjectsWithPagination(1, 1000, $search, $filters, $order, $parseFunction); } /** diff --git a/services/nginx/app/traits/route_t.php b/services/nginx/app/traits/route_t.php index 38d8d793..7a0e82a9 100644 --- a/services/nginx/app/traits/route_t.php +++ b/services/nginx/app/traits/route_t.php @@ -31,6 +31,15 @@ trait route_t { // Get the type of the value $value_type = gettype($value); + // If the type is Array, or Object, json_decode the value to check if it is a valid JSON + if ($type === 'array' || $type === 'object') { + $value = json_decode($value, true); + if (json_last_error() !== JSON_ERROR_NONE) { + global $response; + $response->error('Invalid JSON. Value: ' . $value, 400); + } + $value_type = gettype($value); + } // Check if the value is of the required type if ($value_type !== $type) { global $response; @@ -57,6 +66,16 @@ trait route_t return true; } + /** + * TYPE_ARRAY + * @note This is used to check if the value is an array, this is used in routes to validate input + * @return string + */ + public function TYPE_ARRAY(): string + { + return 'array'; + } + /** * TYPE_NULL * @note This is used to check if the value is null, this is used in routes to validate input @@ -156,6 +175,28 @@ trait route_t return true; } + /** + * Check if the user has a permission + * @param string $permission + * @return bool + */ + public function hasPermission(string $permission): bool + { + global $response; + // Check if the users authorization token has the required permission + try { + $user = (new authentication())->get_user(); + // If there is no user, return an error + if (!$user) { + (new logs_o())->add('global', 'global', 1, 0, 'AUTHENTICATION_FAILED', 'Authentication failed. Invalid, or missing token'); + $response->error('Authentication failed. Invalid or missing token.', 401); + } + return $user->hasPermission($permission); + } catch (\Exception $e) { + $response->error($e->getMessage(), 400); + } + } + /** * Require parameter to be a positive integer * @param int $value The value to check