# ASAT Student Portal — Implementation Documentation

**Project:** Aparri School of Arts and Trades — Online Enrollment Portal
**Module:** Student Portal (Prototype)
**Stack:** Laravel 13 · Livewire v4.2 · Tailwind CSS v3 · MySQL
**Date:** April 2026

---

## Table of Contents

1. [Overview](#1-overview)
2. [Architecture Summary](#2-architecture-summary)
3. [Routes](#3-routes)
4. [Feature Breakdown](#4-feature-breakdown)
   - [4.1 Dashboard](#41-dashboard)
   - [4.2 Application List](#42-application-list)
   - [4.3 New Application (3-Step Form)](#43-new-application-3-step-form)
   - [4.4 Edit Draft Application](#44-edit-draft-application)
   - [4.5 Application Detail](#45-application-detail)
   - [4.6 Document Upload](#46-document-upload)
   - [4.7 Profile Management](#47-profile-management)
   - [4.8 Notifications](#48-notifications)
   - [4.9 Help & FAQ](#49-help--faq)
5. [Database Schema](#5-database-schema)
6. [Security Controls](#6-security-controls)
7. [Status Lifecycle & History](#7-status-lifecycle--history)
8. [Notification System](#8-notification-system)
9. [File Structure Reference](#9-file-structure-reference)
10. [Engineering Notes](#10-engineering-notes)

---

## 1. Overview

The Student Portal is the applicant-facing side of the ASAT Enrollment System. It allows students to:

- Complete a multi-step enrollment application form
- Upload required documents to a private storage disk
- Track their application status through a visual timeline
- Receive in-app and email notifications when their status changes
- Manage their profile and password
- Re-apply after a rejection with pre-filled data
- Access a Help & FAQ section with document requirements and enrollment guides

All student routes are protected by `auth`, `verified`, and `role:student` middleware. Every action is scoped to the authenticated student — no student can read or modify another student's data.

---

## 2. Architecture Summary

```
┌──────────────────────────────────────────────────────┐
│                  Student Browser                      │
└────────────────────────┬─────────────────────────────┘
                         │ HTTPS · CSRF-protected
┌────────────────────────▼─────────────────────────────┐
│              Laravel Router (web.php)                 │
│   Middleware: auth · verified · role:student          │
└──────┬──────────────┬───────────────┬────────────────┘
       │              │               │
  Livewire         Controller      Static
  Components       Actions         Views
  ──────────       ──────────      ──────
  ApplicationForm  show()          dashboard
  ApplicationList  destroy()       help
  DocumentUpload   reApply()       invoices/show
  ProfileEdit
  NotificationList
       │
  Eloquent Models
  ──────────────
  Application  ──►  ApplicationStatusHistory
  Document          (auto-recorded on status change)
  User
  Notification (Laravel built-in)
```

---

## 3. Routes

All routes are prefixed `/student/` with name prefix `student.`.

| Method | URI | Route Name | Handler |
|--------|-----|------------|---------|
| GET | `/student/dashboard` | `student.dashboard` | Closure → `student.dashboard` view |
| GET | `/student/profile` | `student.profile` | `Livewire\Student\ProfileEdit` |
| GET | `/student/notifications` | `student.notifications.index` | `Livewire\Student\NotificationList` |
| POST | `/student/notifications/mark-all-read` | `student.notifications.markAllRead` | `NotificationController@markAllRead` |
| GET | `/student/applications` | `student.applications.index` | `Livewire\Student\ApplicationList` |
| GET | `/student/applications/create` | `student.applications.create` | `Livewire\Student\ApplicationForm` |
| GET | `/student/applications/{applicationId}/edit` | `student.applications.edit` | `Livewire\Student\ApplicationForm` |
| GET | `/student/applications/{application}` | `student.applications.show` | `ApplicationController@show` |
| DELETE | `/student/applications/{application}` | `student.applications.destroy` | `ApplicationController@destroy` |
| POST | `/student/applications/{application}/re-apply` | `student.applications.reApply` | `ApplicationController@reApply` |
| GET | `/student/applications/{application}/documents` | `student.applications.documents` | `Livewire\Student\DocumentUpload` |
| GET | `/student/documents/{document}/download` | `student.documents.download` | `DocumentController@download` |
| GET | `/student/invoices/{invoice}` | `student.invoices.show` | `InvoiceController@show` |
| GET | `/student/help` | `student.help` | Closure → `student.help` view |

> **Route ordering note:** `/applications/create` is declared before `/{application}` to prevent Laravel from treating the string "create" as a model binding ID.

---

## 4. Feature Breakdown

### 4.1 Dashboard

**File:** `resources/views/student/dashboard.blade.php`

Displays a time-based greeting banner and five stat cards:

| Stat | Source |
|------|--------|
| Total Applications | `auth()->user()->applications()->count()` |
| Draft | filtered by `status = draft` |
| Pending Review | `status IN (submitted, under_review)` |
| Approved | `status = approved` |
| Rejected | `status = rejected` |

Also shows a **Recent Applications** table (last 5) and a right sidebar with Quick Actions and a Status Guide legend.

---

### 4.2 Application List

**Livewire:** `App\Livewire\Student\ApplicationList`
**View:** `resources/views/livewire/student/application-list.blade.php`

Features:
- **Status pill tabs** — All / Draft / Submitted / Under Review / Approved / Rejected
- **Keyword search** — debounced 300 ms, matches `reference_number` or `course_applied`
- **Pagination** — 10 per page via Livewire `WithPagination`
- **Per-row actions:**
  - **View** — always visible
  - **Edit** — Draft only → navigates to edit form
  - **Delete** — Draft only → `wire:confirm` dialog before calling `deleteDraft()`
  - **Documents** — Submitted / Under Review only

#### `deleteDraft(int $id)` security
```php
// Ownership + status double-check before delete
$app = Application::where('id', $id)
    ->where('user_id', auth()->id())
    ->where('status', ApplicationStatus::Draft)
    ->firstOrFail();

// Storage cleanup
$app->documents()->each(fn($doc) => Storage::disk('local')->delete($doc->path));
$app->delete();
```

---

### 4.3 New Application (3-Step Form)

**Livewire:** `App\Livewire\Student\ApplicationForm`
**View:** `resources/views/livewire/student/application-form.blade.php`

A 3-step wizard with a visual progress indicator:

| Step | Fields |
|------|--------|
| 1 — Personal Info | first_name, last_name, middle_name, email, phone, address, birth_date, gender |
| 2 — Academic Info | course_id, strand, school_last_attended, year_graduated, academic_year |
| 3 — Review & Submit | Read-only summary; submit button |

**Auto-save as Draft** — each `nextStep()` call writes to the database so data is never lost if the session is interrupted.

**On submit:**
1. Final validation
2. Status updated to `Submitted` (scoped: `user_id + status = draft`)
3. History recorded automatically via `Application::booted()`
4. `ApplicationSubmittedMail` queued to student's email
5. Redirects to Document Upload page

---

### 4.4 Edit Draft Application

**Route:** `GET /student/applications/{applicationId}/edit`
**Component:** Same `ApplicationForm` as create, with `mount(?int $applicationId)` receiving the route segment.

On mount with an ID:
1. Loads the application scoped by `user_id + status = draft` — **aborts 403** if not found or not a Draft
2. Pre-fills all Livewire properties from the saved record
3. Sets `$isEdit = true` (changes header title)

The form submit flow is identical to new applications — the same scoped `saveDraft()` and `submit()` methods handle both.

---

### 4.5 Application Detail

**Controller:** `App\Http\Controllers\Student\ApplicationController@show`
**View:** `resources/views/student/applications/show.blade.php`

Sections:

1. **Status Banner** — contextual banner by status:
   - Draft → yellow "Continue Editing" banner with edit link
   - Approved → green congratulations banner
   - Rejected → red banner with remarks + **Re-Apply** button (POST form)

2. **Personal Information** — read-only grid

3. **Academic Information** — read-only grid + SHS strand if present

4. **Document Checklist & Status**
   - Progress bar: `uploadedRequired / totalRequired × 100%`
   - Checklist grid per `DocumentType` case — green tick if uploaded, red warning if required and missing
   - Uploaded file list via `<x-document-card>`

5. **Application Timeline** — ordered list from `statusHistories`:
   - Colored dot per status (green=approved, red=rejected, amber=under_review, prussian=submitted)
   - Status label + note + changed_by_name + timestamp

6. **Invoice** — links to invoice view if any invoices exist

#### Re-Apply flow
```
POST /student/applications/{rejectedApp}/re-apply
  → validate: status === Rejected
  → rate-limit: activeApplications < 3
  → create new Draft pre-filled from rejected app
  → redirect to edit form
```

---

### 4.6 Document Upload

**Livewire:** `App\Livewire\Student\DocumentUpload`
**View:** `resources/views/livewire/student/document-upload.blade.php`

- Accepts PDF, JPG, JPEG, PNG — max 5 MB per file
- Files stored on the `local` (private) disk at `documents/{user_id}/{application_id}/{uuid}.ext`
- Students can delete their own uploaded documents
- Shows all `DocumentType` cases with upload status indicators

---

### 4.7 Profile Management

**Livewire:** `App\Livewire\Student\ProfileEdit`
**View:** `resources/views/livewire/student/profile-edit.blade.php`

Two independent forms on the same page:

**Personal Information form** — `updateProfile()`

| Field | Validation |
|-------|-----------|
| name | required, string, max:255 |
| phone | nullable, regex: `^(09\|+639)\d{9}$` |
| address | nullable, string, max:500 |
| birth_date | nullable, date, before:today |
| gender | nullable, in:male,female,other |

**Change Password form** — `updatePassword()`

| Field | Validation |
|-------|-----------|
| current_password | required; verified against hash |
| new_password | required, min:8, confirmed |

Read-only account info panel — shows email, role, member since date.

---

### 4.8 Notifications

**Livewire:** `App\Livewire\Student\NotificationList` (WithPagination, 20/page)
**View:** `resources/views/livewire/student/notification-list.blade.php`
**Notification class:** `App\Notifications\ApplicationStatusChanged`

Unread notifications display with:
- Blue highlight row
- Prussian dot indicator
- Bold text
- Per-item "mark as read" check button

**Navigation badge** — the navbar bell icon and the "Notifications" nav link both show an orange badge with the unread count (capped at "9+").

Notifications are triggered automatically by `Application::booted()` when status changes to `UnderReview`, `Approved`, or `Rejected`.

---

### 4.9 Help & FAQ

**Route:** `GET /student/help`
**View:** `resources/views/student/help.blade.php`
Static page — no database queries.

Sections:
- **Step-by-Step Enrollment Guide** — 5 numbered steps
- **Document Requirements Table** — all `DocumentType` cases with Required/Optional tags and descriptions
- **FAQ** — 5 common questions and answers
- **Contact Block** — admissions email, phone, address (prussian card)

---

## 5. Database Schema

### `notifications` (Laravel built-in)

| Column | Type | Notes |
|--------|------|-------|
| id | char(36) | UUID |
| type | varchar | Notification class FQCN |
| notifiable_type | varchar | `App\Models\User` |
| notifiable_id | bigint | User ID |
| data | text | JSON payload |
| read_at | timestamp | null = unread |
| created_at / updated_at | timestamp | — |

**JSON `data` payload for `ApplicationStatusChanged`:**
```json
{
  "application_id": 12,
  "reference_number": "ASAT-2026-000012",
  "old_status": "submitted",
  "new_status": "approved",
  "new_status_label": "Approved",
  "note": "Congratulations!",
  "message": "Your application ASAT-2026-000012 has been updated to: Approved."
}
```

### `application_status_histories`

| Column | Type | Notes |
|--------|------|-------|
| id | bigint | — |
| application_id | bigint FK | cascadeOnDelete |
| from_status | varchar(30) | nullable — null on first entry |
| to_status | varchar(30) | — |
| changed_by_name | varchar(150) | nullable — name string, not FK |
| note | text | nullable — admin remarks at time of change |
| created_at / updated_at | timestamp | — |

Both `from_status` / `to_status` are cast to `ApplicationStatus` enum in the model.

---

## 6. Security Controls

### Authentication & Authorization

| Control | Implementation |
|---------|----------------|
| Route-level auth | `['auth', 'verified', 'role:student']` middleware on all student routes |
| Resource ownership | `ApplicationPolicy::view()` — `user_id === auth()->id()` or admin |
| Policy enforcement | `$this->authorize('view', $application)` in every controller action |

### Application State Guards

| Action | Guard |
|--------|-------|
| Edit application | `abort_if(status !== Draft, 403)` |
| Delete application | `abort_if(status !== Draft, 403)` |
| Re-apply | `abort_if(status !== Rejected, 403)` |
| Save draft in Livewire | Query scoped: `where('user_id', auth()->id())->where('status', Draft)` |
| Submit in Livewire | Query scoped: `user_id + status = draft` before update |

### Rate Limiting

```php
// ApplicationController::reApply()
$activeCount = Application::where('user_id', auth()->id())
    ->whereNotIn('status', [Rejected, Draft])
    ->count();

if ($activeCount >= 3) abort → redirect with error
```

### Input Validation

- All Livewire forms validate per-step before saving
- Phone regex enforced: `^(09|\+639)\d{9}$`
- File uploads: MIME types `pdf|jpg|jpeg|png`, max 5 MB, stored with UUID filenames
- All Blade output is auto-escaped by `{{ }}` — no raw `{!! !!}` usage in student views

### CSRF

All POST/DELETE forms include `@csrf`. Livewire handles its own CSRF token automatically.

---

## 7. Status Lifecycle & History

```
[Draft] ──submit()──► [Submitted] ──admin──► [UnderReview] ──admin──► [Approved]
                                                                  └──► [Rejected] ──reApply()──► [Draft (new)]
```

Status history is recorded **automatically** via `Application::booted()`:

```php
static::updated(function (Application $app) {
    if ($app->wasChanged('status')) {
        ApplicationStatusHistory::create([
            'application_id'  => $app->id,
            'from_status'     => $app->getOriginal('status'),
            'to_status'       => $app->status->value,
            'changed_by_name' => auth()->check() ? auth()->user()->name : 'System',
            'note'            => $app->remarks,
        ]);

        // Notify student on admin-driven changes only
        if (status is UnderReview|Approved|Rejected) {
            $app->user->notify(new ApplicationStatusChanged(...));
        }
    }
});
```

This means **no manual history calls are needed** anywhere — simply updating the `status` field on the model triggers complete auditing and notification.

---

## 8. Notification System

### Channels

| Channel | Trigger | Content |
|---------|---------|---------|
| `database` | Every status change to UnderReview/Approved/Rejected | JSON payload stored in `notifications` table |
| `mail` | Same | Branded email with status, note, and "View Application" link |

### Email Template (`toMail`)

```
Subject: [ASAT] Application ASAT-2026-XXXXXX — Status: {Status}

Hello {name},

Your enrollment application ASAT-2026-XXXXXX has been updated to: {Status}.

Note from Admissions: {note}   ← only shown if note is present

[View Application]

Thank you for applying at Aparri School of Arts and Trades.
```

### In-App Display

Notifications are displayed in `/student/notifications` via `auth()->user()->notifications()->latest()->paginate(20)`. The notification bell in the navbar shows the unread count, refreshed on every page load.

---

## 9. File Structure Reference

```
app/
├── Enums/
│   ├── ApplicationStatus.php        # Draft|Submitted|UnderReview|Approved|Rejected
│   └── DocumentType.php             # form_138|good_moral|birth_certificate|id_photo|medical_certificate|other
│
├── Http/Controllers/Student/
│   ├── ApplicationController.php    # show · destroy · reApply
│   ├── DocumentController.php       # download (private disk)
│   ├── InvoiceController.php        # show
│   └── NotificationController.php   # markAllRead
│
├── Livewire/Student/
│   ├── ApplicationForm.php          # 3-step wizard (create + edit mode)
│   ├── ApplicationList.php          # filtered + paginated list
│   ├── DocumentUpload.php           # file upload/delete
│   ├── NotificationList.php         # paginated notifications
│   └── ProfileEdit.php              # profile + password forms
│
├── Models/
│   ├── Application.php              # booted() history + notify
│   ├── ApplicationStatusHistory.php # from_status/to_status/changed_by_name/note
│   ├── Document.php
│   └── User.php                     # Notifiable, HasRoles
│
├── Notifications/
│   └── ApplicationStatusChanged.php # database + mail
│
└── Policies/
    └── ApplicationPolicy.php        # view · create · update · review

database/migrations/
├── 2026_04_08_000001_create_notifications_table.php
├── 2026_04_08_000002_create_application_status_histories_table.php  ← original (old schema)
└── 2026_04_08_175013_create_application_status_histories_table.php  ← ALTER to new schema

resources/views/
├── layouts/
│   └── navigation.blade.php         # navbar with notification bell + all 6 student links
│
├── livewire/student/
│   ├── application-form.blade.php   # step wizard UI
│   ├── application-list.blade.php   # filter tabs + table + pagination
│   ├── document-upload.blade.php    # upload form + file list
│   ├── notification-list.blade.php  # notification feed
│   └── profile-edit.blade.php       # two-form profile page
│
└── student/
    ├── dashboard.blade.php           # stats + recent apps
    ├── help.blade.php                # guide + docs + FAQ + contact
    └── applications/
        └── show.blade.php            # detail + timeline + checklist + re-apply
```

---

## 10. Engineering Notes

### Livewire Edit Mode Pattern

The `ApplicationForm` component handles both **create** and **edit** in a single class using an optional `?int $applicationId` mount parameter. Laravel/Livewire passes route segments matching the parameter name automatically:

```php
// Route: /applications/{applicationId}/edit
// Livewire mount receives $applicationId automatically
public function mount(?int $applicationId = null): void { ... }
```

### Auto-History via Model Observer (booted)

Using `booted()` instead of manual history calls ensures:
- History is **always** recorded regardless of where the status is changed (Admin, Livewire, seeder, Artisan command)
- No risk of forgetting to record history in a new code path
- Notification dispatch is also centralised here

### Enum Casting in Status History

Both `from_status` and `to_status` in `ApplicationStatusHistory` are cast to `ApplicationStatus` enum. This means timeline templates can call `$history->to_status->label()` directly without `ApplicationStatus::from()` manual conversion.

### LIKE Injection Mitigation

The `ApplicationList` search uses Eloquent parameter binding which prevents SQL injection. However `%` and `_` wildcard characters in the search term are not escaped — for a prototype this is acceptable since a search for `%` simply returns all records. In production, these should be escaped:
```php
$safe = '%' . str_replace(['%', '_', '\\'], ['\\%', '\\_', '\\\\'], $this->search) . '%';
```

### Duplicate Migration Cleanup

During development three duplicate migration files were generated. The resolution:
- Deleted `2026_04_08_100000_create_notifications_table.php` (duplicate)
- Deleted `2026_04_08_100001_create_application_status_histories_table.php` (duplicate)
- Converted `2026_04_08_175013` from `Schema::create` to `Schema::table` (ALTER) to retrofit the existing table with the new column schema

### Intelephense False Positives

Intelephense reports errors on `auth()->user()`, `auth()->check()`, `$this->authorize()`, and `->layout()`. These are all standard Laravel/Livewire patterns that work correctly at runtime. They appear because Intelephense lacks Laravel-specific IDE stubs for facade proxies and trait-injected methods. No action required.
