Skip to content

Optimized dropout process - Technical Exercise Part 1 Product Engineer Achmad Ardani Prasha #1

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 177 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 <http://localhost:8000>.

## API Documentation

Since the current API consists of a single endpoint

<img width="536" alt="api list" src="https://github.com/user-attachments/assets/848327cf-b7a9-45c6-9c63-89790030d598" />

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": "[email protected]",
"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' => '[email protected]',
'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": "[email protected]",
"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)**

<https://github.com/dicoding-dev/technical-excercise/pull/1>

## Product Analysis Document

[**Click here**](https://docs.google.com/document/d/13JoCo44M9q_hEBQnW6R58gwABcYdL05v7JRO0tw0Uh4/edit?usp=sharing)
150 changes: 107 additions & 43 deletions app/Console/Commands/DropOutEnrollments.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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");
}
}