diff --git a/README.md b/README.md index e69de29..4ca5543 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,177 @@ +# Technical Exercise Product Engineer - Enrollment Management System + +This Laravel-based application manages enrollments, exams, and submissions. The API exposed to external clients currently consists of only one endpoint: `/api/user`. This README provides step-by-step setup instructions, API documentation for the available endpoint, guidance on running tests, including how to test the `DropOutEnrollments` command, and product analysis document as part of Dicoding Technical Exercise for Product Engineer. + +## Table of Contents + +- [Setup Instructions](#setup-instructions) +- [API Documentation](#api-documentation) +- [Testing Instructions](#testing-instructions) + - [Testing the API Using Postman](#testing-the-api-using-postman) + - [Testing the DropOutEnrollments Command](#testing-the-dropoutenrollments-command) +- [Product Analysis Document](#product-analysis-document) + +## Setup Instructions + +### Prerequisites + +- PHP \>= 8.1 +- Composer +- MySQL or MariaDB database + +### Installation + +1. **Clone the repository:** + + ```bash + git clone https://github.com/achmadardanip/technical-excercise + cd technical-excercise + ``` + +2. **Install PHP dependencies using Composer:** + + ```bash + composer install + ``` + +3. **Run Migrations and Seed The Database:** + + ```bash + php artisan migrate --seed + ``` + +4. **Run The Application** + + ```bash + php artisan serve + ``` + + The application will be accessible at . + +## API Documentation + +Since the current API consists of a single endpoint + +api list + +Here is the documentation for /api/user: + +### GET /api/user + +* **Description:** + Returns the details of the authenticated user. This endpoint is used in applications where user authentication is required, providing basic information about the logged-in user. + +* **Authentication:** + This endpoint requires an authenticated request (for example, using Laravel Sanctum or Passport). Ensure you include the necessary authentication headers (e.g., Bearer token). + +* **Response Example** + + ``` + { + "id": 1166041, + "name": "Monalisa", + "email": "monalisa@example.com", + "email_verified_at": "2025-03-01T14:58:25.000000Z", + "created_at": "2025-03-01T14:58:25.000000Z", + "updated_at": "2025-03-01T14:58:25.000000Z" + } + ``` + +* **Example Request** + + ``` + curl -X GET http://localhost:8000/api/user \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" + ``` + +## Testing Instructions + +### Testing the API Using Postman + +To test the `/api/user` endpoint using Postman, first create a new user using Tinker and generate an API token. + +* **Create a New User with Tinker:** + + Open your terminal and start Tinker: + + ``` + php artisan tinker + ``` + + Create a new user (this example uses a factory; adjust if needed): + + ``` + $user = App\Models\User::factory()->create([ + 'name' => 'Monalisa', + 'email' => 'monalisa@example.com', + 'password' => bcrypt('secret'), + ]) + ``` + +* **Generate an API Token (if using Laravel Sanctum or Passport):** + + For example, using Laravel Sanctum: + + ``` + $token = $user->createToken('Test Token')->plainTextToken; + echo $token; + ``` + +* **Run The Application** + + Make sure you start the application again after creating a new user, type the following command: + + ```bash + php artisan serve + ``` + +* **Set Up Postman:** + + * Open Postman and create a new GET request to: + + ``` + http://localhost:8000/api/user + ``` + + * In the request headers, add token you've got from previous step: + + ``` + Authorization: Bearer YOUR_GENERATED_TOKEN + ``` + + * Click "Send" to make the request. + +* **Verify the Response:** + + You should receive a JSON response like the following with the user’s details (matching the newly created user). + + ``` + { + "id": 1166041, + "name": "Monalisa", + "email": "monalisa@example.com", + "email_verified_at": "2025-03-01T14:58:25.000000Z", + "created_at": "2025-03-01T14:58:25.000000Z", + "updated_at": "2025-03-01T14:58:25.000000Z" + } + ``` + +### Testing the DropOutEnrollments Command + +- **Run The Command Manually** + + In root directory of the app, run the following command: + + ``` + php artisan enrollments:dropout + ``` + + The command will output details such as the number of enrollments processed, excluded, and dropped out, along with performance metrics. + +- **Link to Pull Request (testing result and optimization steps)** + + + +## Product Analysis Document + +[**Click here**](https://docs.google.com/document/d/13JoCo44M9q_hEBQnW6R58gwABcYdL05v7JRO0tw0Uh4/edit?usp=sharing) diff --git a/app/Console/Commands/DropOutEnrollments.php b/app/Console/Commands/DropOutEnrollments.php index f759d35..ba6525b 100644 --- a/app/Console/Commands/DropOutEnrollments.php +++ b/app/Console/Commands/DropOutEnrollments.php @@ -41,15 +41,19 @@ public function handle() try { DB::beginTransaction(); + // Retrieve the latest deadline (adjust as needed) $deadline = Carbon::parse(Enrollment::latest('id')->value('deadline_at')); $this->stopwatch->start(__CLASS__); + // Call the optimized dropout process. $this->dropOutEnrollmentsBefore($deadline); - $this->stopwatch->stop(__CLASS__); + $event = $this->stopwatch->stop(__CLASS__); + $this->info($this->stopwatch->getEvent(__CLASS__)); + // Note: Rollback is used here for testing/dry-run purposes. DB::rollBack(); } catch (\Exception $e) { DB::rollBack(); @@ -58,50 +62,110 @@ public function handle() } /** - * The dropout process should fulfil the following requirements: - * 1. The enrollment deadline has passed. - * 2. The student has no active exam. - * 3. The student has no submission waiting for review. - * 4. Update the enrollment status to `DROPOUT`. - * 5. Create an activity log for the student. + *Optimized dropout process. + * + * Optimization Documentation: + * + * - **Step 1: Select Only Required Columns** + * - *What:* Instead of retrieving full model data, we select only 'id', 'course_id', and 'student_id'. + * - *Why:* Reduces memory usage. + * - *How:* Use ->select('id', 'course_id', 'student_id'). + * + * - **Step 2: Bulk Fetch Related Records with Composite Keys** + * - *What:* Use selectRaw to retrieve composite keys (course_id-student_id) for Exams and Submissions. + * - *Why:* Minimizes the data loaded from the database. + * - *How:* Use ->selectRaw("CONCAT(course_id, '-', student_id) as composite_key")->distinct()->pluck('composite_key'). + * + * - **Step 3: Cache Timestamp per Chunk** + * - *What:* Call now() only once per chunk. + * - *Why:* Reduces function call overhead. + * - *How:* Store the result in a $now variable. + * + * - **Step 4: Use Chunking, and Bulk Update & Insert** + * - *What:* Process enrollments in chunks, then update and insert in bulk. + * - *Why:* Prevents high memory usage and reduces database roundtrips. + * - *How:* Use chunkById(1000) with DB::table()->whereIn()->update() and DB::table()->insert(). + * + * @param Carbon $deadline */ private function dropOutEnrollmentsBefore(Carbon $deadline) { - $enrollmentsToBeDroppedOut = Enrollment::where('deadline_at', '<=', $deadline)->get(); - - $this->info('Enrollments to be dropped out: ' . count($enrollmentsToBeDroppedOut)); - $droppedOutEnrollments = 0; - - foreach ($enrollmentsToBeDroppedOut as $enrollment) { - $hasActiveExam = Exam::where('course_id', $enrollment->course_id) - ->where('student_id', $enrollment->student_id) - ->where('status', 'IN_PROGRESS') - ->exists(); - - $hasWaitingReviewSubmission = Submission::where('course_id', $enrollment->course_id) - ->where('student_id', $enrollment->student_id) - ->where('status', 'WAITING_REVIEW') - ->exists(); - - if ($hasActiveExam || $hasWaitingReviewSubmission) { - continue; - } - - $enrollment->update([ - 'status' => 'DROPOUT', - 'updated_at' => now(), - ]); - - Activity::create([ - 'resource_id' => $enrollment->id, - 'user_id' => $enrollment->student_id, - 'description' => 'COURSE_DROPOUT', - ]); - - $droppedOutEnrollments++; - } - - $this->info('Excluded from drop out: ' . count($enrollmentsToBeDroppedOut) - $droppedOutEnrollments); - $this->info('Final dropped out enrollments: ' . $droppedOutEnrollments); + $this->info('Starting dropout process...'); + $totalDropped = 0; + $totalChecked = 0; + + // Process enrollments in chunks (only select needed columns) + Enrollment::select('id', 'course_id', 'student_id') + ->where('deadline_at', '<=', $deadline) + ->chunkById(1000, function ($enrollments) use (&$totalDropped, &$totalChecked) { + // Extract unique course_ids and student_ids from the current chunk. + $courseIds = $enrollments->pluck('course_id')->unique()->toArray(); + $studentIds = $enrollments->pluck('student_id')->unique()->toArray(); + + // Build lookup for active exams using composite keys. + $activeExamKeys = Exam::selectRaw("CONCAT(course_id, '-', student_id) as composite_key") + ->whereIn('course_id', $courseIds) + ->whereIn('student_id', $studentIds) + ->where('status', 'IN_PROGRESS') + ->distinct() + ->pluck('composite_key') + ->toArray(); + $activeExamLookup = array_flip($activeExamKeys); + + // Build lookup for waiting submissions using composite keys. + $waitingSubmissionKeys = Submission::selectRaw("CONCAT(course_id, '-', student_id) as composite_key") + ->whereIn('course_id', $courseIds) + ->whereIn('student_id', $studentIds) + ->where('status', 'WAITING_REVIEW') + ->distinct() + ->pluck('composite_key') + ->toArray(); + $waitingSubmissionLookup = array_flip($waitingSubmissionKeys); + + // Prepare arrays for bulk update and bulk insert. + $enrollmentIdsToDrop = []; + $activityLogs = []; + $now = now(); // Cache current timestamp for the entire chunk + + foreach ($enrollments as $enrollment) { + $totalChecked++; + $key = $enrollment->course_id . '-' . $enrollment->student_id; + + // Skip enrollment if it has an active exam or waiting submission. + if (isset($activeExamLookup[$key]) || isset($waitingSubmissionLookup[$key])) { + continue; + } + + $enrollmentIdsToDrop[] = $enrollment->id; + $activityLogs[] = [ + 'resource_id' => $enrollment->id, + 'user_id' => $enrollment->student_id, + 'description' => 'COURSE_DROPOUT', + 'created_at' => $now, + 'updated_at' => $now, + ]; + $totalDropped++; + } + + // Bulk update enrollments that qualify for dropout. + if (!empty($enrollmentIdsToDrop)) { + DB::table('enrollments') + ->whereIn('id', $enrollmentIdsToDrop) + ->update([ + 'status' => 'DROPOUT', + 'updated_at' => $now, + ]); + } + + // Bulk insert all the activity log records. + if (!empty($activityLogs)) { + DB::table('activities')->insert($activityLogs); + } + }); + + // Output process statistics. + $this->info("Enrollments to be dropped out: $totalChecked"); + $this->info("Excluded from drop out: " . ($totalChecked - $totalDropped)); + $this->info("Final dropped out enrollments: $totalDropped"); } }