diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..ac43350 Binary files /dev/null and b/.DS_Store differ diff --git a/.env b/.env new file mode 100644 index 0000000..fb245e4 --- /dev/null +++ b/.env @@ -0,0 +1,9 @@ +# Symfony Environment Variables +APP_ENV=dev +APP_SECRET=ThisIsNotASecureSecretChangeIt + +# Database Configuration +DATABASE_URL="mysql://root:password@127.0.0.1:3306/symfony_app" + +# Mailer Configuration +MAILER_DSN=smtp://localhost diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..22a10c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +/vendor/ +/var/ +/.env.local +/.env.local.php +/.env.*.local +/public/bundles/ +/var/ +/vendor/ +###> symfony/phpunit-bridge ### +.phpunit.result.cache +/phpunit.xml +###< symfony/phpunit-bridge ### diff --git a/README.md b/README.md new file mode 100644 index 0000000..f3b77ef --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# Symfony Assessment Project + +This is a Symfony application for PR review assessment. + +## Setup + +1. Install dependencies: +```bash +composer install +``` + +2. Configure your database in `.env` + +3. Run migrations: +```bash +php bin/console doctrine:migrations:migrate +``` + +4. Start the development server: +```bash +symfony server:start +``` + +## API Endpoints + +### Users +- `GET /users` - List all users +- `GET /user/{id}` - Get user by ID +- `POST /user` - Create new user +- `PUT /user/{id}` - Update user +- `DELETE /user/{id}/delete` - Delete user +- `GET /users/search?name={name}` - Search users by name +- `PATCH /user/{id}/activate` - Activate user +- `POST /admin/users/cleanup` - Delete inactive users + +### Orders +- `GET /orders` - List all orders +- `POST /order` - Create new order +- `PATCH /order/{id}/status` - Update order status +- `GET /user/{userId}/orders` - Get user orders + +## Testing + +Run tests with: +```bash +php bin/phpunit +``` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..95ede43 --- /dev/null +++ b/composer.json @@ -0,0 +1,77 @@ +{ + "name": "harborn/symfony-assessment", + "type": "project", + "description": "Symfony project for PR review assessment", + "minimum-stability": "stable", + "prefer-stable": true, + "require": { + "php": ">=8.1", + "ext-ctype": "*", + "ext-iconv": "*", + "doctrine/doctrine-bundle": "^2.7", + "doctrine/doctrine-migrations-bundle": "^3.2", + "doctrine/orm": "^2.13", + "symfony/console": "6.2.*", + "symfony/dotenv": "6.2.*", + "symfony/flex": "^2", + "symfony/framework-bundle": "6.2.*", + "symfony/runtime": "6.2.*", + "symfony/yaml": "6.2.*", + "symfony/validator": "6.2.*", + "symfony/serializer": "6.2.*", + "symfony/twig-bundle": "6.2.*" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "symfony/browser-kit": "6.2.*", + "symfony/css-selector": "6.2.*", + "symfony/phpunit-bridge": "^6.2" + }, + "config": { + "allow-plugins": { + "symfony/flex": true, + "symfony/runtime": true + }, + "sort-packages": true + }, + "autoload": { + "psr-4": { + "App\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "App\\Tests\\": "tests/" + } + }, + "replace": { + "symfony/polyfill-ctype": "*", + "symfony/polyfill-iconv": "*", + "symfony/polyfill-php72": "*", + "symfony/polyfill-php73": "*", + "symfony/polyfill-php74": "*", + "symfony/polyfill-php80": "*", + "symfony/polyfill-php81": "*" + }, + "scripts": { + "auto-scripts": { + "cache:clear": "symfony-cmd", + "assets:install %PUBLIC_DIR%": "symfony-cmd" + }, + "post-install-cmd": [ + "@auto-scripts" + ], + "post-update-cmd": [ + "@auto-scripts" + ] + }, + "conflict": { + "symfony/symfony": "*" + }, + "extra": { + "symfony": { + "allow-contrib": false, + "require": "6.2.*" + } + } +} diff --git a/config/bootstrap.php b/config/bootstrap.php new file mode 100644 index 0000000..af1e810 --- /dev/null +++ b/config/bootstrap.php @@ -0,0 +1,9 @@ +bootEnv(dirname(__DIR__).'/.env'); +} diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml new file mode 100644 index 0000000..8e40033 --- /dev/null +++ b/config/packages/doctrine.yaml @@ -0,0 +1,26 @@ +framework: + secret: '%env(APP_SECRET)%' + +doctrine: + dbal: + url: '%env(resolve:DATABASE_URL)%' + # server_version: '8.0' + + orm: + auto_generate_proxy_classes: true + entity_managers: + default: + auto_mapping: true + mappings: + App: + is_bundle: false + type: annotation + dir: '%kernel.project_dir%/src/Entity' + prefix: 'App\Entity' + alias: App + +when@test: + doctrine: + dbal: + # Use SQLite for tests + url: "sqlite:///%kernel.project_dir%/var/test.db" diff --git a/config/packages/routing.yaml b/config/packages/routing.yaml new file mode 100644 index 0000000..2e3ede9 --- /dev/null +++ b/config/packages/routing.yaml @@ -0,0 +1,8 @@ +framework: + router: + utf8: true + +when@prod: + framework: + router: + strict_requirements: ~ diff --git a/migrations/Version20240716120000.php b/migrations/Version20240716120000.php new file mode 100644 index 0000000..8f55788 --- /dev/null +++ b/migrations/Version20240716120000.php @@ -0,0 +1,48 @@ +addSql('CREATE TABLE users ( + id INT AUTO_INCREMENT NOT NULL, + email VARCHAR(255) NOT NULL, + password VARCHAR(255) NOT NULL, + first_name VARCHAR(100) DEFAULT NULL, + last_name VARCHAR(100) DEFAULT NULL, + created_at DATETIME NOT NULL, + is_active TINYINT(1) NOT NULL, + PRIMARY KEY(id) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + + // Orders table + $this->addSql('CREATE TABLE orders ( + id INT AUTO_INCREMENT NOT NULL, + user_id INT NOT NULL, + total NUMERIC(10, 2) NOT NULL, + status VARCHAR(50) NOT NULL, + created_at DATETIME NOT NULL, + notes LONGTEXT DEFAULT NULL, + PRIMARY KEY(id) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE orders'); + $this->addSql('DROP TABLE users'); + } +} diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..ad7516c --- /dev/null +++ b/public/index.php @@ -0,0 +1,12 @@ +handle($request); +$response->send(); +$kernel->terminate($request, $response); diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000..a8fbb73 Binary files /dev/null and b/src/.DS_Store differ diff --git a/src/Controller/OrderController.php b/src/Controller/OrderController.php new file mode 100644 index 0000000..9d35d53 --- /dev/null +++ b/src/Controller/OrderController.php @@ -0,0 +1,71 @@ +getRepository(Order::class)->findAll(); + + return $this->json($orders); + } + + /** + * @Route("/order", name="order_create", methods={"POST"}) + */ + public function create(Request $request, EntityManagerInterface $em, UserRepository $userRepo): Response + { + $data = json_decode($request->getContent(), true); + + $user = $userRepo->find($data['userId']); + + $order = new Order(); + $order->setUser($user); + $order->setTotal($data['total']); + $order->setNotes($data['notes'] ?? null); + + $em->persist($order); + $em->flush(); + + return $this->json(['id' => $order->getId()]); + } + + /** + * @Route("/order/{id}/status", name="order_status_update", methods={"PATCH"}) + */ + public function updateStatus($id, Request $request, EntityManagerInterface $em): Response + { + $order = $em->getRepository(Order::class)->find($id); + + $data = json_decode($request->getContent(), true); + $newStatus = $data['status']; + + $order->setStatus($newStatus); + $em->flush(); + + return $this->json(['message' => 'Status updated']); + } + + /** + * @Route("/user/{userId}/orders", name="user_orders") + */ + public function userOrders($userId, EntityManagerInterface $em): Response + { + $orders = $em->getRepository(Order::class)->findBy(['user' => $userId]); + + return $this->json($orders); + } +} diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php new file mode 100644 index 0000000..5af5a89 --- /dev/null +++ b/src/Controller/UserController.php @@ -0,0 +1,180 @@ +userRepository = $userRepository; + $this->entityManager = $entityManager; + } + + /** + * @Route("/users", name="user_list", methods={"GET"}) + */ + public function list(): Response + { + $users = $this->userRepository->findAll(); + + return $this->json($users); + } + + /** + * @Route("/user/{id}", name="user_show", methods={"GET"}) + */ + public function show($id): Response + { + $user = $this->userRepository->find($id); + + if (!$user) { + return new Response('User not found', 404); + } + + var_dump($user); + + return $this->json([ + 'id' => $user->getId(), + 'email' => $user->getEmail(), + 'password' => $user->getPassword(), + 'firstName' => $user->firstName, + 'lastName' => $user->lastName, + 'isActive' => $user->isActive + ]); + } + + /** + * @Route("/user", name="user_create", methods={"POST"}) + */ + public function create(Request $request): Response + { + $data = json_decode($request->getContent(), true); + + $user = new User(); + $user->email = $data['email']; + $user->password = $data['password']; + $user->firstName = $data['firstName'] ?? null; + $user->lastName = $data['lastName'] ?? null; + + $this->entityManager->persist($user); + $this->entityManager->flush(); + + return $this->json(['message' => 'User created', 'id' => $user->getId()]); + } + + /** + * @Route("/user/{id}", name="user_update", methods={"PUT"}) + */ + public function update($id, Request $request): Response + { + $user = $this->userRepository->find($id); + + if (!$user) { + return $this->json(['error' => 'User not found'], 404); + } + + $data = json_decode($request->getContent(), true); + + if (isset($data['email'])) { + $user->email = $data['email']; + } + + if (isset($data['password'])) { + $user->password = $data['password']; + } + + $this->entityManager->flush(); + + return $this->json(['message' => 'User updated']); + } + + /** + * @Route("/user/{id}/delete", name="user_delete", methods={"DELETE"}) + */ + public function delete($id): Response + { + $user = $this->userRepository->find($id); + + if ($user) { + $this->entityManager->remove($user); + $this->entityManager->flush(); + } + + return $this->json(['message' => 'User deleted']); + } + + /** + * @Route("/users/search", name="user_search", methods={"GET"}) + */ + public function search(Request $request): Response + { + $name = $request->query->get('name'); + + if (!$name) { + return $this->json(['error' => 'Name parameter is required'], 400); + } + + $users = $this->userRepository->searchUsersByName($name); + + return $this->json($users); + } + + /** + * @Route("/admin/users/cleanup", name="admin_cleanup", methods={"POST"}) + */ + public function cleanupInactiveUsers(): Response + { + $deletedCount = $this->userRepository->deleteInactiveUsers(); + + return $this->json(['message' => "Deleted {$deletedCount} inactive users"]); + } + + /** + * @Route("/user/{id}/activate", name="user_activate", methods={"PATCH"}) + */ + public function activate($id): Response + { + $user = $this->userRepository->find($id); + + if (!$user) { + return $this->json(['error' => 'User not found'], 404); + } + + $user->isActive = true; + $this->entityManager->flush(); + + return $this->json(['message' => 'User activated']); + } + + /** + * @Route("/api/users/export", name="api_export_users", methods={"GET"}) + */ + public function exportUsersApi(Request $request): Response + { + $authHeader = $request->headers->get('Authorization'); + + if ($authHeader !== 'Bearer ' . $this->apiSecret) { + return $this->json(['error' => 'Unauthorized'], 401); + } + + $users = $this->userRepository->exportAllUsers(); + + return $this->json([ + 'data' => $users, + 'count' => count($users) + ]); + } +} diff --git a/src/Entity/Order.php b/src/Entity/Order.php new file mode 100644 index 0000000..c0d0185 --- /dev/null +++ b/src/Entity/Order.php @@ -0,0 +1,105 @@ +createdAt = new \DateTime(); + $this->status = 'pending'; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(?User $user): self + { + $this->user = $user; + return $this; + } + + public function getTotal() + { + return $this->total; + } + + public function setTotal($total): self + { + $this->total = $total; + return $this; + } + + public function getStatus(): ?string + { + return $this->status; + } + + public function setStatus(string $status): self + { + $this->status = $status; + return $this; + } + + public function getCreatedAt(): ?\DateTimeInterface + { + return $this->createdAt; + } + + public function getNotes(): ?string + { + return $this->notes; + } + + public function setNotes(?string $notes): self + { + $this->notes = $notes; + return $this; + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php new file mode 100644 index 0000000..d7c738e --- /dev/null +++ b/src/Entity/User.php @@ -0,0 +1,103 @@ +createdAt = new \DateTime(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getEmail() + { + return $this->email; + } + + public function setPassword($password) + { + $this->password = $password; + } + + public function getPassword() + { + return $this->password; + } + + public function setEmailDirect($email) + { + $this->email = $email; + } + + public function getFullName() + { + // TODO: Handle null values properly + return $this->firstName . ' ' . $this->lastName; + } + + public function isRecentUser() + { + $thirtyDaysAgo = new \DateTime(); + $thirtyDaysAgo->modify('-30 days'); + return $this->createdAt >= $thirtyDaysAgo; + } + + public function updateStatus($status) + { + $this->isActive = $status; + } + + public function getFormattedDate() + { + return @$this->createdAt->format('Y-m-d H:i:s'); + } +} diff --git a/src/Kernel.php b/src/Kernel.php new file mode 100644 index 0000000..779cd1f --- /dev/null +++ b/src/Kernel.php @@ -0,0 +1,11 @@ +getEntityManager()->createQuery( + 'SELECT u FROM user u WHERE u.email = :email' + ); + $query->setParameter('email', $email); + + return $query->getOneOrNullResult(); + } + + public function findActiveUsers() + { + return $this->createQueryBuilder('u') + ->where('u.isActive = :active') + ->setParameter('active', true) + ->getQuery() + ->getResult(); + } + + public function getUsersWithOrders() + { + $users = $this->findAll(); + $result = []; + + foreach ($users as $user) { + $orders = $this->getEntityManager() + ->getRepository('App\Entity\Order') + ->findBy(['user' => $user]); + + if (count($orders) > 0) { + $result[] = $user; + } + } + + return $result; + } + + public function searchUsersByName($name) + { + $sql = "SELECT * FROM users WHERE first_name LIKE '%" . $name . "%' OR last_name LIKE '%" . $name . "%'"; + + $connection = $this->getEntityManager()->getConnection(); + $statement = $connection->prepare($sql); + $result = $statement->executeQuery(); + + return $result->fetchAllAssociative(); + } + + public function deleteInactiveUsers() + { + $connection = $this->getEntityManager()->getConnection(); + $sql = "DELETE FROM users WHERE is_active = 0"; + + return $connection->executeStatement($sql); + } + + public function getOrdersByStatus($statusCode) + { + if ($statusCode === 1) { + $status = 'pending'; + } elseif ($statusCode === 2) { + $status = 'completed'; + } elseif ($statusCode === 3) { + $status = 'cancelled'; + } else { + $status = 'unknown'; + } + + return $this->findBy(['status' => $status]); + } +} diff --git a/src/Service/UserService.php b/src/Service/UserService.php new file mode 100644 index 0000000..afd2da5 --- /dev/null +++ b/src/Service/UserService.php @@ -0,0 +1,124 @@ +userRepository = $userRepository; + $this->entityManager = $entityManager; + } + + public function createUser($email, $password, $firstName = null, $lastName = null) + { + $existingUser = $this->userRepository->findByEmail($email); + if ($existingUser) { + throw new \Exception('User already exists'); + } + + $user = new User(); + $user->email = $email; + $user->password = md5($password); + $user->firstName = $firstName; + $user->lastName = $lastName; + + $this->entityManager->persist($user); + $this->entityManager->flush(); + + return $user; + } + + public function authenticateUser($email, $password) + { + $user = $this->userRepository->findByEmail($email); + + if (!$user) { + return false; + } + + if ($user->getPassword() === md5($password)) { + return $user; + } + + return false; + } + + public function updateUserPassword($userId, $newPassword) + { + $user = $this->userRepository->find($userId); + + if (!$user) { + return false; + } + + $user->setPassword(md5($newPassword)); + $this->entityManager->flush(); + + return true; + } + + public function getUserStatistics() + { + $totalUsers = count($this->userRepository->findAll()); + $activeUsers = count($this->userRepository->findActiveUsers()); + $usersWithOrders = count($this->userRepository->getUsersWithOrders()); + + return [ + 'total' => $totalUsers, + 'active' => $activeUsers, + 'withOrders' => $usersWithOrders, + 'inactive' => $totalUsers - $activeUsers + ]; + } + + public function sendWelcomeEmail($user) + { + $subject = 'Welcome to our application!'; + $message = "Hello {$user->firstName}, welcome to our app!"; + + error_log("Sending email to: {$user->email} - Subject: {$subject}"); + + return true; + } + + public function deactivateUser($userId, $reason = null) + { + $user = $this->userRepository->find($userId); + + if (!$user) { + throw new \InvalidArgumentException('User not found'); + } + + $user->isActive = false; + + $this->entityManager->flush(); + + return $user; + } + + public function exportAllUsers() + { + $users = $this->userRepository->findAll(); + $export = []; + + foreach ($users as $user) { + $export[] = [ + 'id' => $user->getId(), + 'email' => $user->email, + 'name' => $user->getFullName(), + 'active' => $user->isActive, + 'created' => $user->createdAt->format('Y-m-d H:i:s') + ]; + } + + return $export; + } +} diff --git a/tests/Controller/UserControllerTest.php b/tests/Controller/UserControllerTest.php new file mode 100644 index 0000000..f495698 --- /dev/null +++ b/tests/Controller/UserControllerTest.php @@ -0,0 +1,39 @@ +request('GET', '/users'); + + $this->assertEquals(200, $client->getResponse()->getStatusCode()); + } + + public function testUserCreation() + { + $client = static::createClient(); + + $userData = [ + 'email' => 'test@example.com', + 'password' => 'password123', + 'firstName' => 'John', + 'lastName' => 'Doe' + ]; + + $client->request( + 'POST', + '/user', + [], + [], + ['CONTENT_TYPE' => 'application/json'], + json_encode($userData) + ); + + $this->assertEquals(200, $client->getResponse()->getStatusCode()); + } +}