diff --git a/app/Controllers/Authenticate/AuthenticatePostValidator.php b/app/Controllers/Authenticate/AuthenticatePostValidator.php index d69fe04..9e64391 100644 --- a/app/Controllers/Authenticate/AuthenticatePostValidator.php +++ b/app/Controllers/Authenticate/AuthenticatePostValidator.php @@ -13,7 +13,7 @@ class AuthenticatePostValidator { - private const ALLOWED = ['username', 'password', 'id']; + private const ALLOWED = ['username', 'password', 'id', 'uploaded_files']; /** * @throws JsonException @@ -40,7 +40,7 @@ public function __invoke(Request $request, RequestHandler $handler): ResponseInt } // id can be part of the request, but it MUST be null/empty - if (V::exists()->validate($parsedRequest['id']) && V::notEmpty()->validate($parsedRequest['id'])) { + if (V::key('id')->validate($parsedRequest) && V::notEmpty()->validate($parsedRequest['id'])) { $responseBody->registerParam('invalid', 'id', 'null'); } diff --git a/app/Controllers/DeleteActionBase.php b/app/Controllers/DeleteActionBase.php index 21c6f13..8c3bc31 100644 --- a/app/Controllers/DeleteActionBase.php +++ b/app/Controllers/DeleteActionBase.php @@ -23,13 +23,19 @@ abstract class DeleteActionBase extends ActionBase public function __invoke(Request $request, Response $response, array $args): ResponseInterface { /** @var ResponseBody $responseBody */ $responseBody = $request->getAttribute('response_body'); + $parsedRequest = $responseBody->getParsedRequest(); $model = $this->model; + $id = $args['id']; + $model = $model->find($id); - // Destroy the model given the id. - if ($model::destroy($args['id']) === 1) { - $status = ResponseCodes::HTTP_OK; - } else { - $status = ResponseCodes::HTTP_NOT_FOUND; + $status = ResponseCodes::HTTP_NOT_FOUND; + if ($model !== null) { + if (array_key_exists('force', $parsedRequest) && $parsedRequest['force'] === "true") { + $isDeleted = $model->forceDelete(); + } else { + $isDeleted = $model->delete(); + } + $status= $isDeleted ? ResponseCodes::HTTP_OK : ResponseCodes::HTTP_INTERNAL_SERVER_ERROR; } // Set the status and data of the ResponseBody diff --git a/app/Controllers/File/FileController.php b/app/Controllers/File/FileController.php new file mode 100644 index 0000000..ea0f6e5 --- /dev/null +++ b/app/Controllers/File/FileController.php @@ -0,0 +1,29 @@ +post('/file/upload/{client_id}', FileUploadAction::class) + ->add(FileUploadValidator::class); + + $group->get('/file/download/{id}', FileDownloadAction::class); + + $group->get('/file/{id}', FileGetAction::class); + + $group->post('/file', FileUpdateAction::class); + + $group->delete('/file/{id}', FileDeleteAction::class); + + $group->get('/file/load/{client_id}', FileLoadAction::class); + } +} diff --git a/app/Controllers/File/FileDeleteAction.php b/app/Controllers/File/FileDeleteAction.php new file mode 100644 index 0000000..479c8ae --- /dev/null +++ b/app/Controllers/File/FileDeleteAction.php @@ -0,0 +1,18 @@ +model = $model; + } +} diff --git a/app/Controllers/File/FileDownloadAction.php b/app/Controllers/File/FileDownloadAction.php new file mode 100644 index 0000000..3e00eec --- /dev/null +++ b/app/Controllers/File/FileDownloadAction.php @@ -0,0 +1,61 @@ +getAttribute('response_body'); + + + /** + * Load the File Model with the given id (PK) + * @var File|FileRepresentation|null $fileModel + */ + $fileModel = $this->file->makeVisible('Image')->find($args['id']); + + // If the record is not found then 404 error, otherwise status is 200. + if ($fileModel === null) { + $responseBody = $responseBody + ->setData(null) + ->setStatus(ResponseCodes::HTTP_NOT_FOUND); + return $responseBody(); + } + + return $response + ->withHeader('Content-Type', 'application/octet-stream') + ->withHeader('Content-Disposition', 'attachment; filename="' . $fileModel->FileName . '"') + ->withAddedHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') + ->withHeader('Cache-Control', 'post-check=0, pre-check=0') + ->withHeader('Pragma', 'no-cache') + ->withBody((new StreamFactory())->createStream($fileModel->Image)); + } +} diff --git a/app/Controllers/File/FileGetAction.php b/app/Controllers/File/FileGetAction.php new file mode 100644 index 0000000..a554fe0 --- /dev/null +++ b/app/Controllers/File/FileGetAction.php @@ -0,0 +1,18 @@ +model = $model; + } +} diff --git a/app/Controllers/File/FileLoadAction.php b/app/Controllers/File/FileLoadAction.php new file mode 100644 index 0000000..6a898cf --- /dev/null +++ b/app/Controllers/File/FileLoadAction.php @@ -0,0 +1,59 @@ +getAttribute('response_body'); + + // Load all models for the given client_id + $file = $this->file->clone(); + $documents = $file + ->where('ResidentId', '=', $args['client_id']) + ->orderBy('Updated', 'desc') + ->get(['Id', 'ResidentId', 'FileName', 'Description', 'MediaType', 'Size', 'Created', 'Updated']); + + // If the record is not found then 404 error, otherwise status is 200. + if ($documents !== null && count($documents) > 0) { + $data = $documents->toArray(); + $status = ResponseCodes::HTTP_OK; + } else { + $data = null; + $status = ResponseCodes::HTTP_NOT_FOUND; + } + + // Set the status and data of the ResponseBody + $responseBody = $responseBody + ->setData($data) + ->setStatus($status); + + // Return the response as JSON + return $responseBody(); + } +} diff --git a/app/Controllers/File/FileUpdateAction.php b/app/Controllers/File/FileUpdateAction.php new file mode 100644 index 0000000..a372dac --- /dev/null +++ b/app/Controllers/File/FileUpdateAction.php @@ -0,0 +1,18 @@ +model = $model; + } +} diff --git a/app/Controllers/File/FileUploadAction.php b/app/Controllers/File/FileUploadAction.php new file mode 100644 index 0000000..8cacb4f --- /dev/null +++ b/app/Controllers/File/FileUploadAction.php @@ -0,0 +1,65 @@ +getAttribute('response_body'); + + $parsedRequest = $responseBody->getParsedRequest(); + + /** + * @var $files UploadedFileInterface[] + * @phpstan-ignore-next-line + */ + $files = $parsedRequest['uploaded_files']; + + $file = $files['single_file']; + $clientId = $parsedRequest['client_id']; + + /** @var File|FileRepresentation $document */ + $document = clone $this->file; + $document->ResidentId = $clientId; + $document->Size = $file->getSize(); + $document->FileName = $file->getClientFilename() ?? 'unknown'; + $document->MediaType = $file->getClientMediaType(); + $document->Image = $file->getStream()->getContents(); + if ($document->save()) { + $responseBody = $responseBody->setData( + [ + 'Id' => $document->Id, + 'Size' => $document->Size, + 'FileName' => $document->FileName, + 'Type' => $document->MediaType + ] + )->setStatus(ResponseCodes::HTTP_OK); + return $responseBody(); + } + + $responseBody = $responseBody + ->setData(null) + ->setStatus(ResponseCodes::HTTP_INTERNAL_SERVER_ERROR) + ->setMessage('Unable to save file'); + return $responseBody(); + } +} diff --git a/app/Controllers/File/FileUploadValidator.php b/app/Controllers/File/FileUploadValidator.php new file mode 100644 index 0000000..868531a --- /dev/null +++ b/app/Controllers/File/FileUploadValidator.php @@ -0,0 +1,70 @@ +getAttribute('response_body'); + $parsedRequest = $responseBody->getParsedRequest(); + + /** + * @var $files UploadedFileInterface[] + * @phpstan-ignore-next-line + */ + $files = $parsedRequest['uploaded_files'] ?? []; + + // Only 1 file allowed to be uploaded + if (count($files) !== 1) { + $responseBody->registerParam('required', 'uploaded_files', 'array', 'There must be only one uploaded file'); + } + + // File is hard coded as 'single_file' + $file = $files['single_file'] ?? null; + + // The single_file array element label is required + if ($file === null) { + $responseBody->registerParam('required', 'single_file', 'file', 'File must be labeled as single_file'); + } + + // File size must be under 100MB + if ($file && $file->getSize() > 104_857_600) { + $responseBody->registerParam('invalid', 'single_file', 'file', 'File size exceeds maximum allowed'); + } + + // client_id is required and must be an integer + $clientId = $parsedRequest['client_id'] ?? null; + if (!V::notEmpty()->validate($clientId)) { + $responseBody->registerParam('required', 'client_id', 'integer', 'client_id is empty or invalid'); + } else { + if (!V::intVal()->validate($clientId)) { + $responseBody->registerParam('invalid', 'client_id', 'integer', 'client_id must be an integer'); + } + } + + // If there are any invalid or missing required then send bad request response + if ($responseBody->hasMissingRequiredOrInvalid()) { + $responseBody = $responseBody->setData(null)->setStatus(ResponseCodes::HTTP_BAD_REQUEST); + return $responseBody(); + } + + return $handler->handle($request); + } +} diff --git a/app/Controllers/Pillbox/PillboxLogAction.php b/app/Controllers/Pillbox/PillboxLogAction.php index 62e35e7..fd97dc8 100644 --- a/app/Controllers/Pillbox/PillboxLogAction.php +++ b/app/Controllers/Pillbox/PillboxLogAction.php @@ -10,8 +10,10 @@ use Willow\Middleware\ResponseBody; use Willow\Middleware\ResponseCodes; use Willow\Models\MedHistory; +use Willow\Models\MedHistoryRepresentation; use Willow\Models\Medicine; use Willow\Models\PillboxItem; +use Willow\Models\PillboxItemRepresentation; class PillboxLogAction { @@ -36,7 +38,7 @@ public function __invoke(Request $request, Response $response): ResponseInterfac $pillboxId = $body['pillbox_id']; // Pillbox PK /** - * @var PillboxItem[] $pillboxItems + * @var PillboxItem[]|PillboxItemRepresentation[] $pillboxItems * Get a collection of PillboxItems for the given pillboxId */ $pillboxItems = $this->pillboxItem->where('PillboxId', '=', $pillboxId)->get(); @@ -54,6 +56,9 @@ public function __invoke(Request $request, Response $response): ResponseInterfac $medicineModel = $this->medicine->find($medicineId); // We only log active medications if ($medicineModel && $medicineModel->Active) { + /** + * @var MedHistoryRepresentation|MedHistory $medHistoryModel + */ $medHistoryModel = clone $this->medHistory; $medHistoryModel->PillboxItemId = $id; $medHistoryModel->ResidentId = $pillboxItem->ResidentId; diff --git a/app/Controllers/Pin/PinAuthenticateValidator.php b/app/Controllers/Pin/PinAuthenticateValidator.php index f816995..3425974 100644 --- a/app/Controllers/Pin/PinAuthenticateValidator.php +++ b/app/Controllers/Pin/PinAuthenticateValidator.php @@ -13,7 +13,7 @@ class PinAuthenticateValidator { - private const ALLOWED = ['pin_value']; + private const ALLOWED = ['pin_value', 'uploaded_files']; /** * @throws JsonException diff --git a/app/Controllers/Pin/PinGenerateAction.php b/app/Controllers/Pin/PinGenerateAction.php index a1186eb..0834e01 100644 --- a/app/Controllers/Pin/PinGenerateAction.php +++ b/app/Controllers/Pin/PinGenerateAction.php @@ -11,6 +11,7 @@ use Willow\Middleware\ResponseBody; use Willow\Middleware\ResponseCodes; use Willow\Models\Pin; +use Willow\Models\PinRepresentation; class PinGenerateAction { @@ -38,9 +39,10 @@ public function __invoke(Request $request, Response $response, array $args): Res $parsedRequest = $responseBody->getParsedRequest(); $clientId = $parsedRequest['client_id']; + /** @var Pin|PinRepresentation $pinModel */ $pinModel = clone $this->pin; do { - $pinValue = random_int(10 ** (self::DIGIT_COUNT - 1), (10 ** self::DIGIT_COUNT) -1); + $pinValue = (string)random_int(10 ** (self::DIGIT_COUNT - 1), (10 ** self::DIGIT_COUNT) -1); $pinExists = $this->pin ->where('ResidentId', '=', $clientId) ->where('PinValue', '=', $pinValue) diff --git a/app/Controllers/Resident/ClientLoadAction.php b/app/Controllers/Resident/ClientLoadAction.php index 0ab324d..98788b9 100644 --- a/app/Controllers/Resident/ClientLoadAction.php +++ b/app/Controllers/Resident/ClientLoadAction.php @@ -10,6 +10,7 @@ use Slim\Psr7\Response; use Willow\Middleware\ResponseBody; use Willow\Middleware\ResponseCodes; +use Willow\Models\File; use Willow\Models\MedHistory; use Willow\Models\Medicine; use Willow\Models\Pillbox; @@ -19,11 +20,12 @@ class ClientLoadAction { public function __construct( - private Resident $client, - private Medicine $medicine, - private MedHistory $medHistory, - private Pillbox $pillbox, - private PillboxItem $pillboxItem + private File $file, + private MedHistory $medHistory, + private Medicine $medicine, + private Pillbox $pillbox, + private PillboxItem $pillboxItem, + private Resident $client ) { } @@ -49,6 +51,11 @@ public function __invoke(Request $request, Response $response, array $args): Res $status = ResponseCodes::HTTP_OK; $data = [ 'clientInfo' => $client->attributesToArray(), + 'fileList' => $this->file + ->where('ResidentId', '=', $clientId) + ->orderBy('Updated', 'desc') + ->get(['Id', 'ResidentId', 'FileName', 'Description', 'MediaType', 'Size', 'Created', 'Updated']) + ->toArray(), 'medicineList' => $this->medicine ->where('ResidentId', '=', $clientId) ->orderBy('Drug', 'asc') diff --git a/app/Controllers/SearchValidatorBase.php b/app/Controllers/SearchValidatorBase.php index 81ee733..7f59626 100644 --- a/app/Controllers/SearchValidatorBase.php +++ b/app/Controllers/SearchValidatorBase.php @@ -15,6 +15,7 @@ class SearchValidatorBase extends ActionBase private const ALLOWED_PARAMETER_KEYS = [ 'api_key', 'id', + 'uploaded_files', 'crossJoin', 'distinct', 'first', diff --git a/app/Middleware/RegisterRouteControllers.php b/app/Middleware/RegisterRouteControllers.php index 0bda509..3226102 100644 --- a/app/Middleware/RegisterRouteControllers.php +++ b/app/Middleware/RegisterRouteControllers.php @@ -11,29 +11,32 @@ use Willow\Controllers\PillboxItem\PillboxItemController; use Willow\Controllers\Pin\PinController; use Willow\Controllers\Resident\ResidentController; +use Willow\Controllers\File\FileController; class RegisterRouteControllers { public function __construct( private AuthenticateController $authenticateController, - private MedHistoryController $medHistoryController, - private MedicineController $medicineController, - private PillboxController $pillboxController, - private PillboxItemController $pillboxItemController, - private ResidentController $residentController, - private PinController $pinController + private FileController $fileController, + private MedHistoryController $medHistoryController, + private MedicineController $medicineController, + private PillboxController $pillboxController, + private PillboxItemController $pillboxItemController, + private PinController $pinController, + private ResidentController $residentController ) { } public function __invoke(RouteCollectorProxy $collectorProxy): self { // Register routes and actions for each controller $this->authenticateController->register($collectorProxy); + $this->fileController->register($collectorProxy); $this->medHistoryController->register($collectorProxy); $this->medicineController->register($collectorProxy); $this->pillboxController->register($collectorProxy); $this->pillboxItemController->register($collectorProxy); - $this->residentController->register($collectorProxy); $this->pinController->register($collectorProxy); + $this->residentController->register($collectorProxy); return $this; } } diff --git a/app/Middleware/ResponseBodyFactory.php b/app/Middleware/ResponseBodyFactory.php index 2ffa31e..8d2a812 100644 --- a/app/Middleware/ResponseBodyFactory.php +++ b/app/Middleware/ResponseBodyFactory.php @@ -28,7 +28,8 @@ public function __invoke(Request $request, RequestHandler $handler): ResponseInt array_merge( $arguments, $request->getQueryParams(), - $request->getParsedBody() ?? [] + $request->getParsedBody() ?? [], + ['uploaded_files' => $request->getUploadedFiles()] ) ) ) diff --git a/app/Models/File.php b/app/Models/File.php new file mode 100644 index 0000000..93146e8 --- /dev/null +++ b/app/Models/File.php @@ -0,0 +1,51 @@ +attributes['Description'] = null; + } else { + $this->attributes['Description'] = $value; + } + } +} + diff --git a/app/Models/FileRepresentation.php b/app/Models/FileRepresentation.php new file mode 100644 index 0000000..03416fe --- /dev/null +++ b/app/Models/FileRepresentation.php @@ -0,0 +1,21 @@ +attributes['Drug'] = trim($value); } + /** * Override Strength field to null if empty string * @param string|null $value diff --git a/app/Models/ModelBase.php b/app/Models/ModelBase.php index a361950..d8ce739 100644 --- a/app/Models/ModelBase.php +++ b/app/Models/ModelBase.php @@ -27,7 +27,7 @@ protected static function booted(): void { static::addGlobalScope(new UserScope()); // Save the authenticated UserId value to the model - static::saving(function ($model) { + static::saving(static function ($model) { $model->UserId = UserScope::getUserId(); }); } diff --git a/app/Models/ModelDefaultRules.php b/app/Models/ModelDefaultRules.php index 69512a7..ba056dd 100644 --- a/app/Models/ModelDefaultRules.php +++ b/app/Models/ModelDefaultRules.php @@ -10,7 +10,7 @@ class ModelDefaultRules /** * Allowed request parameters that are not column names */ - private const WHITE_LIST = ['id', 'api_key']; + private const WHITE_LIST = ['id', 'api_key', 'uploaded_files']; /** * @param ResponseBody $responseBody diff --git a/app/Models/PillboxItemRepresentation.php b/app/Models/PillboxItemRepresentation.php new file mode 100644 index 0000000..f906c93 --- /dev/null +++ b/app/Models/PillboxItemRepresentation.php @@ -0,0 +1,30 @@ + + + + + + + + + diff --git a/phpstan.neon b/phpstan.neon index 7c1ebcb..8ec765d 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,23 +1 @@ -parameters: - ignoreErrors: - - - message: '#Access to an undefined property [a-zA-Z0-9\\_]+::\$Quantity#' - path: app/Controllers/Pillbox/PillboxLogAction.php - - - message: '#Access to an undefined property [a-zA-Z0-9\\_]+::\$MedicineId#' - path: app/Controllers/Pillbox/PillboxLogAction.php - - - message: '#Access to an undefined property [a-zA-Z0-9\\_]+::\$Id#' - path: app/Controllers/Pillbox/PillboxLogAction.php - - - message: '#Access to an undefined property [a-zA-Z0-9\\_]+::\$PillboxItemId#' - path: app/Controllers/Pillbox/PillboxLogAction.php - - - message: '#Access to an undefined property [a-zA-Z0-9\\_]+::\$ResidentId#' - path: app/Controllers/Pillbox/PillboxLogAction.php - - - message: '#Access to an undefined property [a-zA-Z0-9\\_]+::\$Notes#' - path: app/Controllers/Pillbox/PillboxLogAction.php - - universalObjectCratesClasses: - - Willow\Models\Pin +# phpstan configuration file diff --git a/sql/File.sql b/sql/File.sql new file mode 100644 index 0000000..38eeea1 --- /dev/null +++ b/sql/File.sql @@ -0,0 +1,18 @@ +CREATE TABLE `File` ( + `Id` int NOT NULL AUTO_INCREMENT, + `ResidentId` int NOT NULL, + `UserId` int NOT NULL, + `FileName` varchar(65) NOT NULL, + `MediaType` varchar(65) DEFAULT NULL, + `Size` int DEFAULT NULL, + `Description` varchar(100) DEFAULT NULL, + `Image` longblob, + `Created` timestamp NULL DEFAULT NULL, + `Updated` timestamp NULL DEFAULT NULL, + `deleted_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`Id`), + KEY `fk_File_User_idx` (`UserId`), + KEY `fk_File_Resident_idx` (`ResidentId`), + CONSTRAINT `fk_File_Resident` FOREIGN KEY (`ResidentId`) REFERENCES `Resident` (`Id`), + CONSTRAINT `fk_File_User` FOREIGN KEY (`UserId`) REFERENCES `User` (`Id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/sql/Pin.sql b/sql/Pin.sql deleted file mode 100644 index a3719cf..0000000 --- a/sql/Pin.sql +++ /dev/null @@ -1,16 +0,0 @@ -CREATE TABLE `Pin` ( - `Id` int NOT NULL AUTO_INCREMENT, - `ResidentId` int NOT NULL, - `UserId` int NOT NULL, - `PinValue` char(6) NOT NULL, - `Image` longblob, - `Created` timestamp NULL DEFAULT NULL, - `Updated` timestamp NULL DEFAULT NULL, - `deleted_at` timestamp NULL DEFAULT NULL, - PRIMARY KEY (`Id`), - UNIQUE KEY `unique_pin` (`ResidentId`,`UserId`,`PinValue`), - KEY `fk_Pin_User` (`UserId`), - KEY `fk_Pin_Resident` (`ResidentId`), - CONSTRAINT `fk_Pin_Resident` FOREIGN KEY (`ResidentId`) REFERENCES `Resident` (`Id`), - CONSTRAINT `fk_Pin_User` FOREIGN KEY (`UserId`) REFERENCES `User` (`Id`) -) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;