From 538aa24dc7ef94a915da545e528e592eb0d93811 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Mon, 22 Sep 2025 12:31:53 +0200 Subject: [PATCH] Add order attachments module - Implemented CRUD operations for order attachments, including adding, listing, downloading, and deleting. - Updated `db_object_t` and `attachments` to enhance object-attachment interactions with new methods for formatting, creating, and managing attachments. - Added new routes (`/orders/attachments` and `/attachments/upload`) for attachment-related functionality. - Adjusted `file_server.php` to handle temp file downloads and attachment storage. - Improved typing and error handling across attachment helper methods and classes. --- services/nginx/app/classes/attachments.php | 19 +- services/nginx/app/file_server.php | 35 +++- .../nginx/app/interfaces/attachments_i.php | 4 +- .../app/objects/object_attachments_o.php | 15 +- .../nginx/app/routes/attachmentsRoute.php | 17 ++ services/nginx/app/routes/ordersRoute.php | 195 ++++++++++++++++++ services/nginx/app/traits/db_object_t.php | 7 +- services/nginx/app/traits/minio_t.php | 39 ++++ 8 files changed, 323 insertions(+), 8 deletions(-) diff --git a/services/nginx/app/classes/attachments.php b/services/nginx/app/classes/attachments.php index 68802364..1a24a11d 100644 --- a/services/nginx/app/classes/attachments.php +++ b/services/nginx/app/classes/attachments.php @@ -49,12 +49,27 @@ class attachments implements attachments_i { return (new object_attachments_o())->select($attachment_id) ?: null; } + /** + * Format an object_attachments_o to an attachment object + * @param object_attachments_o $object_attachments_o The object to format + * @return attachment The formatted attachment + * @throws Exception If the attachment cannot be formatted + */ + public function format(object_attachments_o $object_attachments_o): attachment + { + $data = (object)$object_attachments_o->toArray(); + // If content is JSON, decode it + if (is_string($data->content) && $data->content !== '' && $data->content[0] === '{') { + $data->content = json_decode($data->content, true); + } + return (new attachment())->populate($data); + } /** * @inheritDoc * @throws Exception If the attachment cannot be created */ - public function create(string $type, int $object_id, attachment_content $attachment_content): bool + public function create(string $type, int $object_id, attachment_content $attachment_content): int|bool { $object_attachments_o = new object_attachments_o(); $object_attachments_o->add([ @@ -62,7 +77,7 @@ class attachments implements attachments_i 'object_id' => $object_id, 'content' => json_encode($attachment_content->toArray()), // Store as JSON ]); - return (bool)$object_attachments_o->id; + return $object_attachments_o->id ?: false; } /** diff --git a/services/nginx/app/file_server.php b/services/nginx/app/file_server.php index 3260277f..f158c539 100644 --- a/services/nginx/app/file_server.php +++ b/services/nginx/app/file_server.php @@ -2,11 +2,19 @@ // Get the file name from the URL $file = $_SERVER['REQUEST_URI']; $isPDF = false; +$isPDFStore = false; +$isAttachment = false; // Check if the filetype is .pdf if (preg_match('/\.pdf$/', $file)) { $isPDF = true; + // Check if the file name contains "temp_file_" + if (!str_contains($file, 'temp_file_')) { + $isPDFStore = true; + } else { + $isAttachment = true; + } } -if ($isPDF) { +if ($isPDF && $isPDFStore) { $file = str_replace('/modules/washcertificates/output/certificates/', '', $file); // Download the file from minio, and send it to the client @@ -27,6 +35,29 @@ if ($isPDF) { header('Content-Disposition: inline; filename="' . $file . '"'); header('Content-Length: ' . filesize($certificate_path)); readfile($certificate_path); + // Delete the certificate from /tmp after sending it + unlink($certificate_path); + exit; +} + +// If the file is an attachment, serve it as a download +if ($isAttachment) { + $attachment_store = new \classes\attachment_store(); + $file = str_replace('/files/', '', $file); + // Check if the file exists in the attachment store + if (!$attachment_store->isFileInStore($file)) { + echo 'Attachment not found in store'; + exit; + } + // Download the file from the store to /tmp + $file_path = $attachment_store->download($file); + // Send the file to the client + header('Content-Type: ' . mime_content_type($file_path)); + header('Content-Disposition: attachment; filename="' . basename($file_path) . '"'); + header('Content-Length: ' . filesize($file_path)); + readfile($file_path); + // Delete the file from /tmp after sending it + unlink($file_path); exit; } @@ -47,5 +78,7 @@ if (!$isPDF) { header('Content-Disposition: inline; filename="' . basename($file_path) . '"'); header('Content-Length: ' . filesize($file_path)); readfile($file_path); + // Delete the file from /tmp after sending it + unlink($file_path); exit; } \ No newline at end of file diff --git a/services/nginx/app/interfaces/attachments_i.php b/services/nginx/app/interfaces/attachments_i.php index ddcfd9d1..7378756e 100644 --- a/services/nginx/app/interfaces/attachments_i.php +++ b/services/nginx/app/interfaces/attachments_i.php @@ -26,10 +26,10 @@ interface attachments_i * @param string $type The type of the entity parent. (E.g. users_o, groups_o, etc.) * @param int $object_id The ID of the entity. * @param attachment_content $attachment_content The content of the attachment to be added. - * @return bool True on success, false on failure. + * @return int|bool The ID of the newly created attachment on success, or false on failure. * @see attachment_content */ - public function create(string $type, int $object_id, attachment_content $attachment_content): bool; + public function create(string $type, int $object_id, attachment_content $attachment_content): int|bool; /** * Delete an attachment by its ID. * @param int $attachment_id The ID of the attachment to be deleted. diff --git a/services/nginx/app/objects/object_attachments_o.php b/services/nginx/app/objects/object_attachments_o.php index 42f29c79..99d8f602 100644 --- a/services/nginx/app/objects/object_attachments_o.php +++ b/services/nginx/app/objects/object_attachments_o.php @@ -43,7 +43,7 @@ class object_attachments_o extends db public function getObjectProperties(): void { $this->object_type = new object_property($this->table, $this->id, 'object_type', 'string', false); - $this->object_id = new object_property($this->table, $this->id, 'object_id', 'number', false); + $this->object_id = new object_property($this->table, $this->id, 'object_id', 'int', false); $this->content = new object_property($this->table, $this->id, 'content', 'string', false); $this->created_at = new object_property($this->table, $this->id, 'created_at', 'string', false); $this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'string', false); @@ -54,4 +54,17 @@ class object_attachments_o extends db { //TODO: Add cache invalidation } + + public function toArray(): array + { + return [ + 'id' => (int)$this->id, + 'object_type' => (string)$this->object_type->value(), + 'object_id' => (int)$this->object_id->value(), + 'content' => json_decode($this->content->value(), true), + 'created_at' => $this->created_at->value(), + 'updated_at' => $this->updated_at->value(), + 'deleted_at' => $this->deleted_at->value(), + ]; + } } \ No newline at end of file diff --git a/services/nginx/app/routes/attachmentsRoute.php b/services/nginx/app/routes/attachmentsRoute.php index 11135438..86ee128d 100644 --- a/services/nginx/app/routes/attachmentsRoute.php +++ b/services/nginx/app/routes/attachmentsRoute.php @@ -4,7 +4,9 @@ namespace routes; use attachments\helpers\attachment; use attachments\helpers\attachment_content; +use classes\attachment_store; use classes\attachments; +use classes\response; use objects\orders_o; use traits\route_t; @@ -23,5 +25,20 @@ class attachmentsRoute $orders_o->addAttachment((new attachment_content())->setOther('Hello World!')); $response->success($orders_o->listAttachments()); }); + $this->post('/attachments/upload', function () { + global $response; + self::requireParameters(['base64_image']); + $base64_image = self::getParameter('base64_image'); + //self::requirePermission('modules_scanner_lpr'); + if (empty($base64_image)) { + $response->error('Base64 image is required.'); + } + $uploads = new attachment_store(); + $object_name = $uploads->storeTempImageFromBase64( + $base64_image + ); + echo $object_name; + $response->success(['object_name' => $object_name]); + }); } } \ No newline at end of file diff --git a/services/nginx/app/routes/ordersRoute.php b/services/nginx/app/routes/ordersRoute.php index 378bedaf..36e2d8fe 100644 --- a/services/nginx/app/routes/ordersRoute.php +++ b/services/nginx/app/routes/ordersRoute.php @@ -2,6 +2,9 @@ namespace routes; +use attachments\helpers\attachment_content; +use classes\attachment_store; +use classes\attachments; use classes\authentication; use classes\response; use classes\stripe; @@ -314,6 +317,198 @@ class ordersRoute ] ); + $this->get('/orders/attachments/download', function () { + // Require the user to be logged in + global $response; + $this->requirePermission('download_order_attachments'); + // Get the user object + $user = (new authentication())->get_user(); + // Check if the request was successful + if ($user) { + // Get the order ID and attachment ID from the request + self::requireParameters([ + 'order_id', + 'attachment_id' + ]); + $order_id = self::getParameter('order_id'); + $attachment_id = self::getParameter('attachment_id'); + if (!is_numeric($order_id) || (int)$order_id < 1) { + $response->error('Invalid order ID', 400); + } + if (!is_numeric($attachment_id) || (int)$attachment_id < 1) { + $response->error('Invalid attachment ID', 400); + } + // Get the current order + $order = (new orders_o())->getOrderById((int)$order_id); + // Check if the order exists + if (!$order->exists()) { + $response->error('Order not found', 400); + } + // Get the attachment + $attachment = $order->getAttachment((int)$attachment_id); + if (!$attachment->exists()) { + $response->error('Attachment not found', 400); + } + // Create a download link + $attachment_store = new attachment_store(); + $attachments = new attachments(); + $attachment_formatted = $attachments->format($attachment); + $download_link = $attachment_store->generateDirectDownloadUrl( + $attachment_formatted->content->document + ); + // Log the incident + (new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'DOWNLOAD_ORDER_ATTACHMENT', 'Successfully downloaded an attachment for an order (Order ID: ' . $order_id . ', Attachment ID: ' . $attachment_id . ')'); + // Return the download link + $response->success(['download_link' => $download_link]); + + } else { + // Log the incident + (new logs_o())->add('orders', 'global', 1, 0, 'DOWNLOAD_ORDER_ATTACHMENT', 'No user found, or invalid session'); + // Return an error + $response->error('Invalid session', 400); + } + }, + [ + 'download_order_attachments' => 'Download attachments for an order' + ] + ); + + $this->get('/orders/attachments', function () { + // Require the user to be logged in + global $response; + $this->requirePermission('list_order_attachments'); + // Get the user object + $user = (new authentication())->get_user(); + // Check if the request was successful + if ($user) { + // Get the order ID from the request + self::requireParameters([ + 'id' + ]); + $order_id = self::getParameter('id'); + if (!is_numeric($order_id) || (int)$order_id < 1) { + $response->error('Invalid order ID', 400); + } + // Get the current order + $order = (new orders_o())->getOrderById((int)$order_id); + // Check if the order exists + if (!$order->exists()) { + $response->error('Order not found', 400); + } + // Get the attachments + $attachments = $order->listAttachments(); + // Log the incident + (new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'LIST_ORDER_ATTACHMENTS', 'Successfully listed attachments for an order (ID: ' . $order_id . ')'); + // Return the attachments + $response->success($attachments); + } else { + // Log the incident + (new logs_o())->add('orders', 'global', 1, 0, 'LIST_ORDER_ATTACHMENTS', 'No user found, or invalid session'); + // Return an error + $response->error('Invalid session', 400); + } + }, + [ + 'list_order_attachments' => 'List attachments for an order' + ] + ); + + $this->post('/orders/attachments/upload', function () { + // Require the user to be logged in + global $response; + $this->requirePermission('add_order_attachments'); + // Get the user object + $user = (new authentication())->get_user(); + // Check if the request was successful + if ($user) { + // Check if the order ID and file are set + $this->requireParameters(['order_id', 'base64_file', 'file_name']); + $order_id = (int)$this->getParameter('order_id'); + if (!is_numeric($order_id) || (int)$order_id < 1) { + $response->error('Invalid order ID', 400); + } + // Get the current order + $order = (new orders_o())->getOrderById((int)$order_id); + // Check if the order exists + if (!$order->exists()) { + $response->error('Order not found', 400); + } + // Get the base64 file + $base64_file = (string)$this->getParameter('base64_file'); + $attachment_store = new attachment_store(); + // Determine the file extension from the file name + $file_name = (string)$this->getParameter('file_name'); + $extension = pathinfo($file_name, PATHINFO_EXTENSION); + $object_name = $attachment_store->storeTempFileFromBase64( + $base64_file, + $extension + ); + if ($object_name === false) { + $response->error('Failed to store the attachment file.', 500); + } + $object_attachment = $order->addAttachment((new attachment_content())->setDocument($object_name)->setOther((string)self::getParameter('file_name'))); + // Log the incident + (new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'ADD_ORDER_ATTACHMENT', 'Successfully added an attachment for an order (Order ID: ' . $order_id . ')'); + // Return a success message + $attachments = new attachments(); + $response->success($attachments->format($attachments->get($object_attachment->id))); + } else { + // Log the incident + (new logs_o())->add('orders', 'global', 1, 0, 'ADD_ORDER_ATTACHMENT', 'No user found, or invalid session'); + // Return an error + $response->error('Invalid session', 400); + } + }, + [ + 'add_order_attachments' => 'Add attachments for an order' + ] + ); + + $this->delete('/orders/attachments', function () { + // Require the user to be logged in + global $response; + $this->requirePermission('delete_order_attachments'); + // Get the user object + $user = (new authentication())->get_user(); + // Check if the request was successful + if ($user) { + // Get the order ID and attachment ID from the request + self::requireParameters([ + 'order_id', + 'attachment_id' + ]); + $order_id = self::getParameter('order_id'); + $attachment_id = self::getParameter('attachment_id'); + if (!is_numeric($order_id) || (int)$order_id < 1) { + $response->error('Invalid order ID', 400); + } + if (!is_numeric($attachment_id) || (int)$attachment_id < 1) { + $response->error('Invalid attachment ID', 400); + } + // Get the current order + $order = (new orders_o())->getOrderById((int)$order_id); + // Check if the order exists + if (!$order->exists()) { + $response->error('Order not found', 400); + } + // Delete the attachment + $order->removeAttachment((int)$attachment_id); + // Log the incident + (new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'DELETE_ORDER_ATTACHMENT', 'Successfully deleted an attachment for an order (Order ID: ' . $order_id . ', Attachment ID: ' . $attachment_id . ')'); + // Return a success message + $response->success(['message' => 'Attachment deleted successfully']); + } else { + // Log the incident + (new logs_o())->add('orders', 'global', 1, 0, 'DELETE_ORDER_ATTACHMENT', 'No user found, or invalid session'); + // Return an error + $response->error('Invalid session', 400); + } + }, + [ + 'delete_order_attachments' => 'Delete attachments for an order' + ] + ); + $this->post('/orders/mark_as_completed', function () { // Require the user to be logged in global $response; diff --git a/services/nginx/app/traits/db_object_t.php b/services/nginx/app/traits/db_object_t.php index 8765547c..1303b7e6 100644 --- a/services/nginx/app/traits/db_object_t.php +++ b/services/nginx/app/traits/db_object_t.php @@ -2,6 +2,7 @@ namespace traits; +use attachments\helpers\attachment; use attachments\helpers\attachment_content; use classes\attachments; use classes\db; @@ -1151,14 +1152,16 @@ trait db_object_t /** * Add an attachment to the (current) object * @param attachment_content $content The content of the attachment + * @return object_attachments_o The attachment object * @throws Exception If the object is not selected, it throws an exception */ - public function addAttachment(attachment_content $content): void + public function addAttachment(attachment_content $content): object_attachments_o { self::requireSelected(); // Add the attachment to the object $attachment = new attachments(); - $attachment->create($this->table, $this->id, $content); + $id = $attachment->create($this->table, $this->id, $content); + return (new object_attachments_o())->select($id); } /** * List attachments of the (current) object diff --git a/services/nginx/app/traits/minio_t.php b/services/nginx/app/traits/minio_t.php index 4ffbf832..9f335f84 100644 --- a/services/nginx/app/traits/minio_t.php +++ b/services/nginx/app/traits/minio_t.php @@ -189,6 +189,45 @@ trait minio_t return self::getS3Client()->doesObjectExist(self::getBucket(), $key); } + /** + * Store temp file from base64 string + * @param string $base64 The base64 encoded string + * @param string $fileExtension The file extension (e.g., 'txt', 'jpg') + * @return string|bool The path to the temporary file if successful, false otherwise + */ + public function storeTempFileFromBase64(string $base64, string $fileExtension = 'txt'): string|bool + { + // Check if the base64 string is valid + if (empty($base64) || !preg_match('/^data:.*;base64,/', $base64)) { + return false; // Invalid base64 string + } + // Decode the base64 data + $fileData = base64_decode(preg_replace('/^data:.*;base64,/', '', $base64)); + if ($fileData === false) { + return false; // Failed to decode base64 data + } + // Temporary file path + $tempFileName = uniqid('temp_file_', true) . '.' . $fileExtension; + $tempFilePath = '/tmp/' . $tempFileName; + // Create a temporary file + $tempFile = fopen($tempFilePath, 'wb'); + if ($tempFile === false) { + return false; // Failed to create temp file + } + + // Write the file data to the temporary file + if (fwrite($tempFile, $fileData) === false) { + fclose($tempFile); + return false; // Failed to write to temp file + } + fclose($tempFile); + // Upload the temporary file to the bucket + $result = self::uploadFile($tempFileName, $tempFilePath); + // Clean up the temporary file + unlink($tempFilePath); + return $result ? $tempFileName : false; + } + /** * Store temp image file from base64 string * @param string $base64 The base64 encoded image string (E.g. data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAA)