PHASE 2 — Route Feature Map (Complete Vertical Slice Reference)¶
System: PyroRadius ISP Billing & RADIUS Management
Stack: Laravel 13 · Inertia.js v2 · React 18 · PostgreSQL 16
Last Updated: 2026-07-13
Document: PHASE2_01
This is the definitive route-to-feature reference. Every route is documented as a full vertical slice: HTTP layer → controller → service → model → database → frontend. Use this when tracing a feature end-to-end or onboarding into a new module.
Table of Contents¶
- Auth Group
- Dashboard Group
- Customers Group
- Billing Group
- Packages Group
- NAS Group
- OLT Group
- WhatsApp Group
- Campaign Group
- Reports Group
- Health & Cron Group
- Settings Group
- Users Group
- Import Group
- Audit Group
- Tickets Group
- TR-069 Group
- Graphs Group
- Engineer Group
- Customer Portal Group
- Mobile API Group
- Internal Ajax API Group
- Route Statistics
1. Auth Group¶
Routes in routes/auth.php. No auth middleware — publicly accessible (except logout).
GET /login¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /login |
| Controller | AuthController@showLogin |
| Middleware | web (no auth) |
| Permission | None (public) |
| Inertia Page | Pages/Auth/Login.jsx |
| React Components | LoginForm, LogoHeader, FlashMessage |
Request Parameters: None
Business Logic:
1. If user is already authenticated, redirect to /dashboard
2. Otherwise render the Inertia login page
3. Flash errors from previous failed attempt are injected via shared props
Services Called: None
Models Touched: None
Database Tables: None
Related APIs: None
Related Features: Session management, remember-me cookie flow
POST /login¶
| Field | Value |
|---|---|
| Method | POST |
| URI | /login |
| Controller | AuthController@login |
| Middleware | web, throttle:5,1 (5 attempts per minute per IP+email) |
| Permission | None (public) |
| Inertia Page | Redirects to /dashboard on success |
| React Components | N/A (server redirect) |
Request Parameters:
- email — required, string, email format
- password — required, string, min 8
- remember — optional, boolean
Business Logic:
1. Validate input (email required, password required)
2. Check rate limiter: if > 5 failures for IP+email combo in 1 min → return 429 with lockout message
3. Auth::attempt(['email' => $email, 'password' => $password], $remember)
- Laravel queries users WHERE email = ? LIMIT 1
- Verifies password_verify($password, $user->password)
4. On FAILURE:
- Increment rate limiter counter
- Redirect back with validation error: "These credentials do not match our records."
5. On SUCCESS:
- Session::regenerate() — prevents session fixation
- Store user ID in encrypted session under key login_web_*
- If $remember = true → set long-lived remember_web_* cookie with hashed remember_token
- Redirect to /dashboard (or intended URL if user was redirected to login)
- AuditLogger records login event with IP and user_agent
Services Called: AuditLogger::log('user.login', ...)
Models Touched: User
Database Tables: users (read), audit_logs (write)
Related APIs: None
Related Features: Remember-me, session security, rate limiting
POST /logout¶
| Field | Value |
|---|---|
| Method | POST |
| URI | /logout |
| Controller | AuthController@logout |
| Middleware | web, auth |
| Permission | None (any authenticated user) |
| Inertia Page | Redirects to /login |
| React Components | N/A |
Request Parameters: None (CSRF token via header)
Business Logic:
1. Auth::logout() — invalidates session, clears remember token from DB
2. Session::invalidate() — destroys the session
3. Session::regenerateToken() — new CSRF token
4. Clear viewing_as_dealer from session if set
5. Redirect to /login
Services Called: None
Models Touched: User (clears remember_token)
Database Tables: users (remember_token nulled), Redis session deleted
Related APIs: None
Related Features: View As session cleanup
2. Dashboard Group¶
Prefix: / — Middleware: auth
GET /dashboard (or GET /)¶
| Field | Value |
|---|---|
| Method | GET |
| URI | / or /dashboard |
| Controller | DashboardController@index |
| Middleware | auth |
| Permission | All authenticated users |
| Inertia Page | Pages/Dashboard/Index.jsx |
| React Components | StatCard, OnlineNowWidget, RevenueChart, ExpiryAlertBanner, QuickActions |
Request Parameters: None
Business Logic:
1. Determine scope: if viewing_as_dealer in session → scope as that dealer; else use auth()->user() scope
2. Compute stats (admin sees all, dealer sees own scope):
- total_customers — COUNT from customers (active scope)
- active_customers — COUNT WHERE status = 'active'
- expired_customers — COUNT WHERE expiry_date < NOW()
- online_now — read from Redis cache key online_count (set by RADIUS auth listener); fallback to COUNT radacct WHERE acctstoptime IS NULL
- monthly_revenue — SUM of payments in current calendar month (dealer_id scoped)
- outstanding_balance — SUM of unpaid invoices (dealer_id scoped)
3. Expiry alerts — customers expiring in next 3 days
4. Return all stats to Inertia as page props
Services Called: RedisService (read online_count), BillingService::getOutstanding()
Models Touched: Customer, Payment, Invoice
Database Tables: customers (read), payments (read), invoices (read), Redis (read)
Related APIs: GET /api/dashboard/online (polled every 30s by frontend)
Related Features: Online Now widget, Expiry alerts, View As
POST /dashboard/view-as¶
| Field | Value |
|---|---|
| Method | POST |
| URI | /dashboard/view-as |
| Controller | DashboardController@viewAs |
| Middleware | auth |
| Permission | super_admin or admin only |
| Inertia Page | Redirects to /dashboard |
| React Components | N/A |
Request Parameters:
- dealer_id — required, integer, must exist in users WHERE role = 'dealer'
Business Logic:
1. Abort 403 if current user is not admin or super_admin
2. Validate dealer_id exists
3. Store dealer_id in session: session(['viewing_as_dealer' => $dealerId])
4. AuditLogger logs: admin.view_as with {admin_id, dealer_id}
5. Redirect to /dashboard — all subsequent requests now scoped as that dealer
Services Called: AuditLogger::log('admin.view_as', ...)
Models Touched: User
Database Tables: users (read to validate dealer), audit_logs (write), Redis session (write)
Related APIs: None
Related Features: View As banner in UI, dealer impersonation, session scoping
GET /api/dashboard/online¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /api/dashboard/online |
| Controller | Api\DashboardController@online |
| Middleware | auth |
| Permission | All authenticated users |
| Inertia Page | None (JSON response) |
| React Components | Consumed by OnlineNowWidget |
Request Parameters: None
Business Logic:
1. Read Redis key online_count (integer — maintained by RADIUS auth/acct listener)
2. For admin: return system-wide count
3. For dealer: return count of online customers WHERE dealer_id = $user->id (from radacct JOIN customers)
4. Response: {"count": N, "timestamp": "..."}
Services Called: RedisService
Models Touched: None directly
Database Tables: radacct (read if cache miss), Redis (read)
Related APIs: —
Related Features: Dashboard Online Now widget (polled every 30s)
3. Customers Group¶
Prefix: /customers — Middleware: auth
GET /customers¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /customers |
| Controller | CustomerController@index |
| Middleware | auth |
| Permission | customers.view |
| Inertia Page | Pages/Customers/Index.jsx |
| React Components | CustomerTable, CustomerFilters, BulkToolbar, StatusBadge, PaginationBar |
Request Parameters (query):
- search — string, searches name / pppoe_username / phone / address
- status — enum: active, expired, suspended, disconnected
- dealer_id — integer (admin only filter)
- package_id — integer
- area_id — integer
- connection_type — string: pppoe, static, ftth
- page — integer (pagination)
- per_page — integer (default 25)
Business Logic:
1. Check customers.view permission → abort 403 if missing
2. Build base query: Customer::with(['package', 'dealer', 'connectionType', 'areaRef'])
3. Apply dealer scope via visibleTo($user) scope:
- admin/super_admin → no filter
- dealer → WHERE dealer_id = $user->id
- sub_dealer → WHERE sub_dealer_id = $user->id
- field_engineer → WHERE dealer_id = $user->parent_id
- collection_officer → WHERE dealer_id IN (assigned) OR area_id IN (assigned)
4. Apply search filters from query params
5. Paginate with ->paginate($perPage)->withQueryString()
6. Return to Inertia with filters (for URL state preservation)
Services Called: None
Models Touched: Customer, Package, User (dealer), Area, ConnectionType
Database Tables: customers, packages, users, areas, connection_types (all read)
Related APIs: GET /api/customers/filter (used for advanced filter UI), GET /api/customers (search typeahead)
Related Features: Bulk toolbar (bulk status change), CSV export, filter persistence
GET /customers/create¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /customers/create |
| Controller | CustomerController@create |
| Middleware | auth |
| Permission | customers.create |
| Inertia Page | Pages/Customers/Create.jsx |
| React Components | CustomerForm, PackageSelect, AreaSelect, NasSelect, ConnectionTypeSelect, DealerSelect (admin only) |
Request Parameters: None
Business Logic:
1. Check customers.create permission → abort 403
2. Load form data:
- packages — dealer's assigned packages (via dealer_package pivot) or all packages for admin
- areas — all areas or dealer's areas
- dealers — all dealers (admin only)
- nas_devices — active NAS list
- connection_types — all connection types
- olts — GPON OLTs if FTTH feature enabled
3. Return Inertia with form data as props
Services Called: None
Models Touched: Package, Area, Nas, ConnectionType, Olt, User
Database Tables: packages, dealer_package, areas, nas, connection_types, olts (all read)
Related APIs: None
Related Features: FTTH ONT provisioning, PPPoE credential generation
POST /customers¶
| Field | Value |
|---|---|
| Method | POST |
| URI | /customers |
| Controller | CustomerController@store |
| Middleware | auth |
| Permission | customers.create |
| Inertia Page | Redirects to customers.show on success |
| React Components | N/A |
Request Parameters (body):
- name — required, string
- phone — required, string
- email — optional, email
- address — required, string
- area_id — required, integer
- package_id — required, integer
- connection_type_id — required, integer
- nas_id — required, integer
- pppoe_username — required if PPPoE, unique in customers
- pppoe_password — required if PPPoE
- static_ip — optional, for static type
- expiry_date — required, date
- dealer_id — admin only (otherwise set to auth()->id())
- sub_dealer_id — optional
- olt_id, ont_serial — if FTTH
- notes — optional
Business Logic:
1. Validate all fields
2. For dealers: force dealer_id = auth()->id() (cannot assign to another dealer)
3. Wrap in DB transaction:
a. Customer::create($validated) with explicit dealer_id
b. RadiusService::syncCustomer($customer) → writes to radcheck, radreply, radusergroup in RADIUS DB
c. If FTTH: OltService::provisionOnt($customer, $ont_serial)
4. AuditLogger::log('customer.created', ['customer_id' => $customer->id, ...])
5. Optionally: WhatsAppService::sendWelcome($customer) if setting enabled
6. Redirect to customers.show with success flash
Services Called: RadiusService::syncCustomer(), AuditLogger::log(), WhatsAppService::sendWelcome() (optional)
Models Touched: Customer
Database Tables: customers (insert), radcheck (insert/update), radreply (insert/update), radusergroup (insert/update), audit_logs (insert), whatsapp_outbox (insert if welcome enabled)
Related APIs: None
Related Features: RADIUS provisioning, FTTH ONT provisioning, WhatsApp welcome message
GET /customers/{id}¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /customers/{customer} |
| Controller | CustomerController@show |
| Middleware | auth |
| Permission | customers.view |
| Inertia Page | Pages/Customers/Show.jsx |
| React Components | CustomerHeader, CustomerInfoTab, CustomerLedgerTab, CustomerInvoicesTab, CustomerOntTab, RadiusStatusCard, ActionButtons |
Request Parameters: None (route model binding: {customer} → Customer::find($id))
Business Logic:
1. Check customers.view permission → abort 403
2. IDOR check: if not admin, verify customer->dealer_id === $user->id (or sub_dealer logic)
3. Load relationships:
- $customer->load(['package', 'dealer', 'subDealer', 'area', 'nas', 'connectionType'])
- Recent invoices: Invoice::where('customer_id', $id)->latest()->take(10)->get()
- Recent payments: Payment::where('customer_id', $id)->latest()->take(10)->get()
- Ledger summary: running balance from ledger_entries
- RADIUS status: RadiusService::getCustomerStatus($customer->pppoe_username) → checks radcheck + radacct
- ONT info (if FTTH): OltService::getOntStatus($customer->ont_serial)
4. Pass to Inertia with permission flags: canEdit, canDelete, canRecharge, canDisconnect
Services Called: RadiusService::getCustomerStatus(), OltService::getOntStatus() (if FTTH)
Models Touched: Customer, Invoice, Payment, LedgerEntry, Package, Area, Nas
Database Tables: customers, invoices, payments, ledger_entries, radcheck, radacct, radreply (all read)
Related APIs: GET /api/customers/{customer}/ledger, GET /api/radius/{username}/status
Related Features: Recharge button, Kick/disconnect, PPPoE credential view, ONT signal tab, traffic graphs tab
GET /customers/{id}/edit¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /customers/{customer}/edit |
| Controller | CustomerController@edit |
| Middleware | auth |
| Permission | customers.edit |
| Inertia Page | Pages/Customers/Edit.jsx |
| React Components | CustomerForm (edit mode), same as Create |
Business Logic: Same as create but pre-populates form with existing customer data. IDOR check applied.
PUT /customers/{id}¶
| Field | Value |
|---|---|
| Method | PUT |
| URI | /customers/{customer} |
| Controller | CustomerController@update |
| Middleware | auth |
| Permission | customers.edit |
| Inertia Page | Redirects to customers.show |
| React Components | N/A |
Request Parameters: Same as POST /customers (partial update allowed)
Business Logic:
1. Validate, check permission, IDOR check
2. Lock-for-update with retry loop to handle PostgreSQL 55P03 (lock not available) errors — up to 3 retries with 50ms sleep
3. $customer->update($validated)
4. If pppoe_username, pppoe_password, package_id, status, or expiry_date changed:
- RadiusService::syncCustomer($customer) → update radcheck/radreply/radusergroup
5. AuditLogger::log('customer.updated', ['before' => $old, 'after' => $new])
6. Redirect with success flash
Services Called: RadiusService::syncCustomer(), AuditLogger::log()
Models Touched: Customer
Database Tables: customers (update), radcheck, radreply, radusergroup (update), audit_logs (insert)
Related APIs: None
Related Features: PPPoE credential change, package change (triggers RADIUS group update), expiry edit
DELETE /customers/{id}¶
| Field | Value |
|---|---|
| Method | DELETE |
| URI | /customers/{customer} |
| Controller | CustomerController@destroy |
| Middleware | auth |
| Permission | customers.delete |
| Inertia Page | Redirects to customers.index |
| React Components | N/A |
Request Parameters (body):
- delete_reason — required, string
Business Logic:
1. Check permission and IDOR
2. Soft delete: set deleted_at = NOW(), deleted_by = $user->id, delete_reason = $reason
3. RadiusService::deleteCustomerRadius($customer->pppoe_username) → removes rows from radcheck, radreply, radusergroup in RADIUS DB (customer can no longer authenticate)
4. If active RADIUS session: send CoA Disconnect (kick session)
5. AuditLogger::log('customer.deleted', [...])
Services Called: RadiusService::deleteCustomerRadius(), RadiusService::kickSession() (if online)
Models Touched: Customer
Database Tables: customers (soft delete update), radcheck, radreply, radusergroup (delete), audit_logs (insert)
Related APIs: None
Related Features: Deleted customers list, restore flow
POST /customers/{id}/recharge¶
| Field | Value |
|---|---|
| Method | POST |
| URI | /customers/{customer}/recharge |
| Controller | CustomerController@recharge |
| Middleware | auth |
| Permission | customers.recharge (or billing.collect) |
| Inertia Page | Redirects back to customers.show |
| React Components | N/A (called from RechargeModal) |
Request Parameters (body):
- amount — required, numeric
- payment_method — required, enum: cash, bank_transfer, easypaisa, jazzcash
- months — optional, integer (default 1)
- notes — optional
Business Logic:
1. Check permission and IDOR
2. Wrap in DB transaction:
a. BillingService::generateInvoice($customer, $months) → creates Invoice record
b. BillingService::recordPayment($invoice, $amount, $method) → creates Payment record, updates LedgerEntry
c. BillingService::renewCustomer($customer, $months) → advances expiry_date by N months, sets status = 'active'
3. RadiusService::syncCustomer($customer) → updates expiry attribute in radcheck so RADIUS picks up new expiry
4. WhatsAppService::sendRechargeConfirmation($customer, $payment) → queues WhatsApp message if enabled
5. AuditLogger::log('customer.recharged', [...])
6. Redirect with success flash
Services Called: BillingService::generateInvoice(), BillingService::recordPayment(), BillingService::renewCustomer(), RadiusService::syncCustomer(), WhatsAppService::sendRechargeConfirmation()
Models Touched: Customer, Invoice, Payment, LedgerEntry
Database Tables: customers (expiry_date update), invoices (insert), payments (insert), ledger_entries (insert), radcheck (update), whatsapp_outbox (insert), audit_logs (insert)
Related APIs: None
Related Features: Billing cycle, WhatsApp receipt, RADIUS re-authentication trigger
POST /customers/{id}/kick (disconnect)¶
| Field | Value |
|---|---|
| Method | POST |
| URI | /customers/{customer}/kick |
| Controller | CustomerController@kick |
| Middleware | auth |
| Permission | customers.disconnect |
| Inertia Page | Redirects back |
| React Components | N/A |
Business Logic:
1. Check permission and IDOR
2. RadiusService::kickSession($customer->pppoe_username) →
- Finds active session in radacct for username
- Sends RADIUS CoA Disconnect-Request packet to NAS IP via radclient
- Tries port 3799 first (standard), falls back to 1700 (MikroTik CoA)
- Waits for Disconnect-ACK or times out after 5s
3. Log result to audit_logs
4. Flash success or failure message
Services Called: RadiusService::kickSession()
Models Touched: Customer
Database Tables: radacct (read), audit_logs (insert)
Related APIs: None
Related Features: Real-time session management, NAS CoA
GET /customers/deleted¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /customers/deleted |
| Controller | CustomerController@deleted |
| Middleware | auth |
| Permission | customers.deleted.view |
| Inertia Page | Pages/Customers/Deleted.jsx |
| React Components | DeletedCustomerTable, RestoreButton |
Business Logic:
1. Customer::onlyTrashed()->with([...])->visibleTo($user)->paginate()
2. Shows: name, pppoe_username, deleted_at, deleted_by (user name), delete_reason
POST /customers/{id}/restore¶
| Field | Value |
|---|---|
| Method | POST |
| URI | /customers/{customer}/restore |
| Controller | CustomerController@restore |
| Middleware | auth |
| Permission | customers.restore |
| Inertia Page | Redirects to customers.show |
| React Components | N/A |
Business Logic:
1. Customer::withTrashed()->findOrFail($id)->restore() — clears deleted_at, deleted_by, delete_reason
2. RadiusService::syncCustomer($customer) — re-creates RADIUS entries
3. AuditLogger::log('customer.restored', [...])
GET /customers/{id}/graphs¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /customers/{customer}/graphs |
| Controller | GraphController@customer |
| Middleware | auth |
| Permission | network.graphs |
| Inertia Page | Pages/Graphs/Customer.jsx |
| React Components | TrafficChart, SessionHistoryTable |
Business Logic:
1. IDOR check
2. RadiusService::getTrafficHistory($customer->pppoe_username, $days) — queries radacct for upload/download bytes per session
3. Returns time-series data for chart rendering
GET /customers/{id}/troubleshoot¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /customers/{customer}/troubleshoot |
| Controller | CustomerController@troubleshoot |
| Middleware | auth |
| Permission | customers.view |
| Inertia Page | Pages/Customers/Troubleshoot.jsx |
| React Components | DiagnosticPanel, RadiusInfoCard, SessionCard, OntSignalCard |
Business Logic: 1. Aggregate diagnostic data: - RADIUS radcheck/radreply entries for this user - Current radacct session (if any) - Last 5 accounting records - If FTTH: ONT signal (rx/tx optical power) - NAS reachability status 2. Return all diagnostic info as props
4. Billing Group¶
Prefix: /billing — Middleware: auth
GET /billing/invoices¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /billing/invoices |
| Controller | InvoiceController@index |
| Middleware | auth |
| Permission | billing.invoices |
| Inertia Page | Pages/Billing/Invoices/Index.jsx |
| React Components | InvoiceTable, InvoiceFilters, StatusBadge, PaginationBar |
Request Parameters (query): search, status (paid/unpaid/partial), from_date, to_date, dealer_id (admin), page
Business Logic:
1. Check billing.invoices permission
2. Invoice::with(['customer', 'payments'])->dealerScoped($user)->filter($request)->paginate(25)
3. Dealer-scoped: WHERE dealer_id = $user->id
Database Tables: invoices, payments, customers (read)
POST /billing/invoices¶
| Field | Value |
|---|---|
| Method | POST |
| URI | /billing/invoices |
| Controller | InvoiceController@store |
| Middleware | auth |
| Permission | billing.invoices |
| Inertia Page | Redirects to billing.invoices.show |
| React Components | N/A |
Business Logic:
1. BillingService::generateInvoice($customer, $months, $options) — creates Invoice with line items
2. AuditLogger::log('invoice.created', [...])
GET /billing/invoices/{id}¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /billing/invoices/{invoice} |
| Controller | InvoiceController@show |
| Middleware | auth |
| Permission | billing.invoices |
| Inertia Page | Pages/Billing/Invoices/Show.jsx |
| React Components | InvoiceDetail, PaymentHistory, PrintButton, PDFDownloadButton |
Business Logic: Load invoice with customer, payments, line items. IDOR check via dealer_id. Render full invoice detail.
GET /billing/invoices/{id}/pdf¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /billing/invoices/{invoice}/pdf |
| Controller | InvoiceController@pdf |
| Middleware | auth |
| Permission | billing.invoices |
| Inertia Page | None (PDF binary response) |
| React Components | N/A |
Business Logic: Generate PDF using Laravel DomPDF or Snappy. Returns Content-Type: application/pdf response for download/print.
GET /billing/defaulters¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /billing/defaulters |
| Controller | InvoiceController@defaulters |
| Middleware | auth |
| Permission | billing.view |
| Inertia Page | Pages/Billing/Defaulters.jsx |
| React Components | DefaulterTable, SendReminderButton, BulkWhatsAppButton |
Business Logic:
1. Customer::with(['package', 'invoices'])->whereHas('invoices', fn($q) => $q->unpaid())->visibleTo($user)->get()
2. Calculate: days_overdue, outstanding_amount per customer
3. Admins/dealers can trigger WhatsApp reminders from this view
GET /billing/outbox¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /billing/outbox |
| Controller | OutboxController@index |
| Middleware | auth |
| Permission | whatsapp.queue |
| Inertia Page | Pages/Billing/Outbox.jsx |
| React Components | OutboxTable, RetryButton, StatusBadge |
Business Logic: Paginated list of whatsapp_outbox records. Shows: recipient, message preview, status (pending/sent/failed), created_at, attempts.
GET /billing/archive¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /billing/archive |
| Controller | BillingArchiveController@index |
| Middleware | auth |
| Permission | billing.view |
| Inertia Page | Pages/Billing/Archive/Index.jsx |
| React Components | ArchiveMonthList, ArchiveStatCard |
POST /billing/archive¶
| Field | Value |
|---|---|
| Method | POST |
| URI | /billing/archive |
| Controller | BillingArchiveController@store |
| Middleware | auth |
| Permission | billing.manage (admin only) |
| Inertia Page | Redirects back |
| React Components | N/A |
Business Logic:
1. Run month-end archive: snapshot current month's invoices/payments into billing_archives table
2. Generate aggregate summary: total invoices, total collected, total outstanding, per-dealer breakdown
3. Mark month as archived — cannot be re-archived
POST /billing/archive/{id}/restore¶
| Field | Value |
|---|---|
| Method | POST |
| URI | /billing/archive/{month}/restore |
| Controller | BillingArchiveController@restore |
| Middleware | auth |
| Permission | billing.manage (super_admin only) |
| Inertia Page | Redirects back |
Business Logic: Un-archive a billing month. Restores archived records to active state. Logs to audit_logs.
5. Packages Group¶
Prefix: /packages — Middleware: auth
GET /packages¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /packages |
| Controller | PackageController@index |
| Middleware | auth |
| Permission | admin.packages |
| Inertia Page | Pages/Packages/Index.jsx |
| React Components | PackageTable, PackageCard, AddPackageButton |
Business Logic: List all packages with: name, speed_download, speed_upload, price, type (PPPoE/FTTH), group_name (RADIUS group), active customer count.
POST /packages¶
| Field | Value |
|---|---|
| Method | POST |
| URI | /packages |
| Controller | PackageController@store |
| Middleware | auth |
| Permission | admin.packages |
| Inertia Page | Redirects to packages.index |
Request Parameters: name, speed_download, speed_upload, burst_download, burst_limit_threshold, price, connection_type, radius_group_name, description
Business Logic:
1. Validate and create Package record
2. RadiusService::syncPackage($package) → creates/updates radgroupreply entries for all registered NAS vendors (MikroTik: rate-limit, Cisco: Cisco-AVPair, Huawei: HW-Data-Filter)
3. AuditLogger::log('package.created', [...])
Database Tables: packages (insert), radgroupreply (insert/update), audit_logs (insert)
PUT /packages/{id}¶
| Field | Value |
|---|---|
| Method | PUT |
| URI | /packages/{package} |
| Controller | PackageController@update |
| Middleware | auth |
| Permission | admin.packages |
| Inertia Page | Redirects to packages.index |
Business Logic:
1. Update Package record
2. RadiusService::syncPackage($package) → updates radgroupreply for ALL vendors that have this group — so existing customers on this package immediately get the new speed on next auth
3. AuditLogger::log('package.updated', ['before' => $old, 'after' => $new])
6. NAS Group¶
Prefix: /nas — Middleware: auth
GET /nas¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /nas |
| Controller | NasController@index |
| Middleware | auth |
| Permission | network.nas_view |
| Inertia Page | Pages/Nas/Index.jsx |
| React Components | NasTable, NasStatusCard, AddNasButton |
Business Logic: List all NAS devices from nas table. For each: name, shortname, type, nastype, ports, server (RADIUS secret), active session count from radacct.
POST /nas¶
| Field | Value |
|---|---|
| Method | POST |
| URI | /nas |
| Controller | NasController@store |
| Middleware | auth |
| Permission | network.nas_manage |
| Inertia Page | Redirects to nas.index |
Request Parameters: nasname (IP), shortname, type, ports, secret, server, community, description, vendor (mikrotik/cisco/huawei)
Business Logic: Create NAS record in both nas table (local config) and FreeRADIUS nas table (for RADIUS auth). The secret is the shared secret for RADIUS communication.
GET /nas/{id}/sessions¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /nas/{nas}/sessions |
| Controller | NasController@sessions |
| Middleware | auth |
| Permission | network.nas_view |
| Inertia Page | Pages/Nas/Sessions.jsx |
| React Components | SessionTable, DisconnectButton |
Business Logic: Query radacct WHERE nasipaddress = $nas->nasname AND acctstoptime IS NULL to get live sessions.
GET /api/nas/{id}/traffic¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /api/nas/{nas}/traffic |
| Controller | Api\NasController@traffic |
| Middleware | auth |
| Permission | network.nas_view |
| Inertia Page | None (JSON) |
Business Logic: MikroTikService::liveTraffic($nas) → connects to MikroTik API on port 8728 or 8729 (SSL), fetches /interface/monitor-traffic for WAN interface. Returns real-time bps/pps data. Used by NAS detail page charts.
7. OLT Group¶
Prefix: /olt — Middleware: auth
GET /olt¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /olt |
| Controller | OltController@index |
| Middleware | auth |
| Permission | network.gpon_view |
| Inertia Page | Pages/Olt/Index.jsx |
| React Components | OltTable, OltStatusCard |
Business Logic: List all OLT devices. Show: name, IP, vendor, total ONTs, registered ONTs, unregistered ONTs count.
GET /olt/{id}/onts¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /olt/{olt}/onts |
| Controller | OltController@onts |
| Middleware | auth |
| Permission | network.gpon_view |
| Inertia Page | Pages/Customers/OntList.jsx |
| React Components | OntTable, OntStatusBadge, SignalLevel |
Business Logic: List all ONTs registered on this OLT, joined with customers to show which customer each ONT belongs to. Show: serial, status, rx_power, tx_power, distance, customer name.
GET /olt/{id}/unregistered¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /olt/{olt}/unregistered |
| Controller | OltController@unregistered |
| Middleware | auth |
| Permission | network.gpon_view |
| Inertia Page | Pages/Customers/UnregisteredOnts.jsx |
| React Components | UnregisteredOntTable, ProvisionButton |
Business Logic: Fetch ONTs from OLT that are not in customers.ont_serial column. These are physically connected but not yet provisioned. Shows: serial, PON port, signal. Admin can click Provision to assign to a customer.
8. WhatsApp Group¶
Prefix: /whatsapp — Middleware: auth
GET /whatsapp¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /whatsapp |
| Controller | WhatsAppController@index |
| Middleware | auth |
| Permission | whatsapp.view |
| Inertia Page | Pages/WhatsApp/Index.jsx |
| React Components | WhatsAppDashboard, SessionStatusCard, OutboxSummary, QRCodeModal |
Business Logic: Dashboard showing WhatsApp session status (connected/disconnected), pending outbox count, today's sent count, failed count.
GET /whatsapp/templates¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /whatsapp/templates |
| Controller | WhatsAppController@templates |
| Middleware | auth |
| Permission | whatsapp.templates |
| Inertia Page | Pages/WhatsApp/Templates.jsx |
| React Components | TemplateList, TemplateEditor, VariableReference |
Business Logic: List all message templates from whatsapp_templates. Templates use {name}, {expiry}, {amount}, {package} placeholders.
POST /whatsapp/templates¶
| Field | Value |
|---|---|
| Method | POST |
| URI | /whatsapp/templates |
| Controller | WhatsAppController@storeTemplate |
| Middleware | auth |
| Permission | whatsapp.templates |
| Inertia Page | Redirects back |
Request Parameters: name, body (message text with placeholders), trigger_event (recharge/expiry_reminder/welcome/custom)
POST /whatsapp/campaigns¶
| Field | Value |
|---|---|
| Method | POST |
| URI | /campaigns |
| Controller | CampaignController@store |
| Middleware | auth |
| Permission | whatsapp.campaigns |
| Inertia Page | Redirects to campaigns.show |
Request Parameters: name, template_id, recipient_filter (all/expired/active/custom), scheduled_at, dealer_ids (admin scoping)
Business Logic:
1. Create Campaign record
2. Dispatch SendCampaignJob to queue → job resolves recipients → creates one whatsapp_outbox record per recipient → WhatsAppService::send() processes queue
GET /whatsapp/sessions¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /whatsapp/sessions |
| Controller | WhatsAppController@sessions |
| Middleware | auth |
| Permission | whatsapp.sessions |
| Inertia Page | Pages/WhatsApp/Sessions.jsx |
| React Components | QRCodeScanner, SessionStatusCard, RegenerateButton |
Business Logic: Shows active Baileys/WPPConnect session status. If disconnected, generates QR code for re-linking WhatsApp. QR code is fetched from the Node.js WhatsApp bridge service.
9. Campaign Group¶
Prefix: /campaigns — Middleware: auth
GET /campaigns¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /campaigns |
| Controller | CampaignController@index |
| Middleware | auth |
| Permission | whatsapp.campaigns |
| Inertia Page | Pages/Campaigns/Index.jsx |
| React Components | CampaignTable, CampaignStatusBadge |
GET /campaigns/{id}¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /campaigns/{campaign} |
| Controller | CampaignController@show |
| Middleware | auth |
| Permission | whatsapp.campaigns |
| Inertia Page | Pages/Campaigns/Show.jsx |
| React Components | CampaignProgress, RecipientList, DeliveryStats |
Business Logic: Show campaign progress: total recipients, sent count, delivered count, failed count. Polls /api/campaigns/{id}/progress for live update.
10. Reports Group¶
Prefix: /reports — Middleware: auth
GET /reports¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /reports |
| Controller | ReportController@index |
| Middleware | auth |
| Permission | reports.view |
| Inertia Page | Pages/Reports/Index.jsx |
| React Components | ReportCard, ReportNavigationList |
Business Logic: Landing page showing available reports for the current user's permission level.
GET /reports/revenue¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /reports/revenue |
| Controller | ReportController@revenue |
| Middleware | auth |
| Permission | reports.billing |
| Inertia Page | Pages/Reports/Revenue.jsx |
| React Components | RevenueChart, MonthlyTable, ExportButton |
Business Logic: Monthly revenue by dealer. Sums payments.amount grouped by DATE_TRUNC('month', paid_at) and dealer_id. Admin sees all; dealer sees own.
GET /reports/defaulters¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /reports/defaulters |
| Controller | ReportController@defaulters |
| Middleware | auth |
| Permission | reports.recovery |
| Inertia Page | Pages/Reports/Defaulters.jsx |
| React Components | DefaulterTable, ExportButton |
GET /reports/collections¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /reports/collections |
| Controller | ReportController@collections |
| Middleware | auth |
| Permission | reports.billing |
| Inertia Page | Pages/Reports/Collections.jsx |
| React Components | CollectionTable, PeriodSelector, DealerFilter |
Request Parameters (query): from_date, to_date, dealer_id, group_by (day/week/month)
GET /reports/{type}/export¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /reports/{type}/export |
| Controller | ReportController@export |
| Middleware | auth |
| Permission | reports.export |
| Inertia Page | None (file download) |
Business Logic: Generates Excel/CSV export using Laravel Excel (Maatwebsite). Dealer-scoped. Types: customers, revenue, defaulters, collections, radius, ftth, area, package.
11. Health & Cron Group¶
Prefix: /health and /settings/cron — Middleware: auth
GET /health¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /health |
| Controller | HealthController@index |
| Middleware | auth |
| Permission | admin.health |
| Inertia Page | Pages/Health/Index.jsx |
| React Components | ServerStatsCard, QueueStatusCard, NasHealthList, CronJobList, RedisStatusCard |
Business Logic:
1. Server stats: CPU, RAM, disk (via sys_getloadavg() and df)
2. Queue: check Laravel Horizon or queue worker status via Artisan queue:size
3. Redis: Redis::ping() → check connectivity
4. Cron jobs: CronJob::all() → list with last_run_at, next_run_at, status
5. NAS health: ping each NAS IP → record response time
GET /health/cron¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /health/cron (or /settings/cron) |
| Controller | CronJobController@index |
| Middleware | auth |
| Permission | admin.health |
| Inertia Page | Pages/Health/CronJobs.jsx |
| React Components | CronJobTable, CronJobForm, EnableToggle |
Business Logic: List all records from cron_jobs table. Shows: name, schedule (cron expression), last_run_at, next_run_at, enabled, last_status.
POST /health/cron¶
| Field | Value |
|---|---|
| Method | POST |
| URI | /health/cron |
| Controller | CronJobController@store |
| Middleware | auth |
| Permission | admin.health |
Request Parameters: name, schedule (cron expression), command (Artisan command), enabled
PUT /health/cron/{id}¶
| Field | Value |
|---|---|
| Method | PUT |
| URI | /health/cron/{job} |
| Controller | CronJobController@update |
| Middleware | auth |
| Permission | admin.health |
Business Logic: Update schedule, enable/disable. Laravel Scheduler reads this table to determine which jobs to run.
DELETE /health/cron/{id}¶
| Field | Value |
|---|---|
| Method | DELETE |
| URI | /health/cron/{job} |
| Controller | CronJobController@destroy |
| Middleware | auth |
| Permission | admin.health |
12. Settings Group¶
Prefix: /settings — Middleware: auth
GET /settings¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /settings |
| Controller | SettingsController@general |
| Middleware | auth |
| Permission | admin.masters (super_admin or admin) |
| Inertia Page | Pages/Settings/General.jsx |
| React Components | SettingsForm, SettingGroup, SaveButton |
Business Logic: Load all settings from settings key-value table via Setting::all(). Group into sections: General, Billing, RADIUS, WhatsApp, Notifications.
PUT /settings¶
| Field | Value |
|---|---|
| Method | PUT |
| URI | /settings |
| Controller | SettingsController@updateGeneral |
| Middleware | auth |
| Permission | admin.masters |
| Inertia Page | Redirects back |
Business Logic:
1. Validate each setting key/value pair
2. Setting::set($key, $value) for each changed setting → upsert in settings table
3. Clear app_settings Redis cache (so HandleInertiaRequests picks up new values)
4. AuditLogger::log('settings.updated', [...])
GET /settings/company¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /settings/company |
| Controller | SettingsController@company |
| Middleware | auth |
| Permission | admin.masters |
| Inertia Page | Pages/Settings/Company.jsx |
| React Components | CompanyForm, LogoUploader |
PUT /settings/company¶
| Field | Value |
|---|---|
| Method | PUT |
| URI | /settings/company |
| Controller | SettingsController@updateCompany |
| Middleware | auth |
| Permission | admin.masters |
Business Logic: Update company profile (name, address, phone, email, logo). Logo uploaded to storage/app/public/logo. Settings saved to settings table under company.* keys.
GET /settings/radius¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /settings/radius |
| Controller | SettingsController@radius |
| Middleware | auth |
| Permission | admin.masters |
| Inertia Page | Pages/Settings/Radius.jsx |
Business Logic: RADIUS server configuration: host, port, DB connection, CoA port, retry count.
13. Users Group¶
Prefix: /users — Middleware: auth (admin only)
GET /users¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /users |
| Controller | UserController@index |
| Middleware | auth |
| Permission | admin.users |
| Inertia Page | Pages/Users/Index.jsx |
| React Components | UserTable, RoleBadge, CreateUserButton |
Business Logic: List all users (dealers, sub_dealers, admins). Admin sees only dealers/sub_dealers they manage. super_admin sees all including other admins.
POST /users¶
| Field | Value |
|---|---|
| Method | POST |
| URI | /users |
| Controller | UserController@store |
| Middleware | auth |
| Permission | admin.users |
| Inertia Page | Redirects to users.edit |
Request Parameters: name, email, password, role (dealer/sub_dealer/noc_admin/billing_admin/etc.), dealer_id (if sub_dealer), permissions (JSONB object), phone, address
Business Logic:
1. Validate: email unique, role valid, for sub_dealer → dealer_id required
2. User::create([...]) with hashed password
3. AuditLogger::log('user.created', [...])
PUT /users/{id}¶
| Field | Value |
|---|---|
| Method | PUT |
| URI | /users/{user} |
| Controller | UserController@update |
| Middleware | auth |
| Permission | admin.users |
| Inertia Page | Redirects back |
Request Parameters: Same as store. permissions JSONB replaces existing permissions atomically.
Business Logic:
1. Validate. Cannot change own role (prevents lockout). Cannot grant a sub_dealer permissions the parent dealer doesn't have.
2. Update users record
3. AuditLogger::log('user.permissions_updated', ['before' => $oldPermissions, 'after' => $newPermissions])
GET /users/{id}/packages¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /users/{user}/packages |
| Controller | DealerPackageController@index |
| Middleware | auth |
| Permission | admin.users |
| Inertia Page | Pages/Users/Packages.jsx |
| React Components | DealerPackageTable, PriceOverrideInput, AssignPackageButton |
Business Logic: Manages the dealer_package pivot table. Shows: which packages are assigned to this dealer, dealer-specific price per package. Admin can assign/unassign packages and set custom prices.
14. Import Group¶
Prefix: /import — Middleware: auth
GET /import¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /import |
| Controller | ImportController@index |
| Middleware | auth |
| Permission | admin.import.customers |
| Inertia Page | Pages/Import/Index.jsx |
| React Components | ImportDropzone, TemplateDownloadButton, ImportLogList |
POST /import/customers¶
| Field | Value |
|---|---|
| Method | POST |
| URI | /import/customers |
| Controller | ImportController@importCustomers |
| Middleware | auth |
| Permission | admin.import.customers |
| Inertia Page | Redirects to import.logs |
Request Parameters: file (XLS/XLSX/CSV), dealer_id (required for admin), dry_run (boolean — validate without saving)
Business Logic:
1. Upload file to temp storage
2. Parse using Laravel Excel (Maatwebsite)
3. Validate each row: required fields, unique pppoe_username, valid package_id, valid area
4. If dry_run: return validation report (N valid, M errors) without saving
5. If valid: dispatch ImportCustomersJob to queue
6. Job processes rows in batches of 50: create customer + RadiusService::syncCustomer() per row
7. Log results to import_logs table
Database Tables: customers (insert), radcheck, radreply, radusergroup (insert), import_logs (insert)
15. Audit Group¶
Prefix: /audit — Middleware: auth
GET /audit¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /audit |
| Controller | AuditController@index |
| Middleware | auth |
| Permission | admin.audit |
| Inertia Page | Pages/Audit/Index.jsx |
| React Components | AuditLogTable, AuditFilters, AuditEntryModal |
Request Parameters (query): event, user_id, from_date, to_date, search (target description)
Business Logic:
1. AuditLog::with(['user'])->filter($request)->latest()->paginate(50)
2. Admin sees all audit logs. super_admin only can prune.
3. Each row: event, user who did it, target (customer/invoice/user name), old_values, new_values (JSONB diff), ip_address, user_agent, created_at
Database Tables: audit_logs (read), users (join for user name)
GET /audit/export¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /audit/export |
| Controller | AuditController@export |
| Middleware | auth |
| Permission | admin.audit |
| Inertia Page | None (CSV download) |
16. Tickets Group¶
Prefix: /tickets — Middleware: auth
GET /tickets¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /tickets |
| Controller | TicketController@index |
| Middleware | auth |
| Permission | tickets.view |
| Inertia Page | Pages/Tickets/Index.jsx |
| React Components | TicketTable, TicketFilters, StatusBadge, PriorityBadge |
Request Parameters (query): status (open/in_progress/resolved/closed), priority, assigned_to, dealer_id
POST /tickets¶
| Field | Value |
|---|---|
| Method | POST |
| URI | /tickets |
| Controller | TicketController@store |
| Middleware | auth |
| Permission | tickets.create |
| Inertia Page | Redirects to tickets.show |
Request Parameters: customer_id, title, description, priority (low/medium/high/critical), category
PUT /tickets/{id}¶
| Field | Value |
|---|---|
| Method | PUT |
| URI | /tickets/{ticket} |
| Controller | TicketController@update |
| Middleware | auth |
| Permission | tickets.manage |
| Inertia Page | Redirects back |
Business Logic: Update status, assigned_to (engineer), resolution notes. Triggers notification to assigned engineer if assignment changes.
POST /tickets/{id}/comments¶
| Field | Value |
|---|---|
| Method | POST |
| URI | /tickets/{ticket}/comments |
| Controller | TicketController@comment |
| Middleware | auth |
| Permission | tickets.view (any authenticated user can comment on tickets they can view) |
| Inertia Page | Redirects back to tickets.show |
Request Parameters: body (comment text), is_internal (boolean — internal note or customer-visible)
17. TR-069 Group¶
Prefix: /tr069 — Middleware: auth
GET /tr069¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /tr069 |
| Controller | Tr069Controller@index |
| Middleware | auth |
| Permission | tr069.view |
| Inertia Page | Pages/Tr069/Index.jsx |
| React Components | DeviceList, DeviceStatusCard, GenieACSLink |
Business Logic: Lists CPE devices managed via TR-069/GenieACS. Shows: device ID, model, IP, last contact, firmware version, status.
GET /tr069/devices/{id}¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /tr069/devices/{device} |
| Controller | Tr069Controller@show |
| Middleware | auth |
| Permission | tr069.view |
| Inertia Page | Pages/Tr069/Device.jsx |
| React Components | DeviceInfoCard, ParameterTree, RebootButton, FactoryResetButton, FirmwareUpgradeButton |
POST /tr069/devices/{id}/provision¶
| Field | Value |
|---|---|
| Method | POST |
| URI | /tr069/devices/{device}/provision |
| Controller | Tr069Controller@provision |
| Middleware | auth |
| Permission | tr069.manage |
| Inertia Page | Redirects back |
Business Logic: Push provisioning parameters to CPE via GenieACS API: SSID, WiFi password, PPPoE credentials, DNS settings. Dispatches Tr069ProvisionJob to queue.
18. Graphs Group¶
Prefix: /graphs — Middleware: auth
GET /graphs/nas/{id}¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /graphs/nas/{nas} |
| Controller | GraphController@nas |
| Middleware | auth |
| Permission | network.graphs |
| Inertia Page | Pages/Graphs/Nas.jsx |
| React Components | TrafficChart, SessionCountChart, TimeRangeSelector |
Business Logic: Fetches traffic history for a NAS device. Queries nas_traffic_log table (populated by background SNMP/MikroTik polling job). Returns time-series: timestamp, bytes_in, bytes_out, session_count.
GET /graphs/customer/{id}¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /graphs/customer/{customer} |
| Controller | GraphController@customer |
| Middleware | auth |
| Permission | network.graphs |
| Inertia Page | Pages/Graphs/Customer.jsx |
| React Components | TrafficChart, SessionHistoryTable |
Business Logic: IDOR check. Queries radacct for customer's session history: acctinputoctets, acctoutputoctets per session. Aggregates by day for chart.
19. Engineer Group¶
Prefix: /engineer — Middleware: auth
GET /engineer¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /engineer |
| Controller | EngineerController@dashboard |
| Middleware | auth |
| Permission | admin.field_engineers |
| Inertia Page | Pages/Engineer/Dashboard.jsx |
| React Components | TaskList, EngineerMap, AssignmentCard |
GET /engineer/tasks¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /engineer/tasks |
| Controller | EngineerController@tasks |
| Middleware | auth |
| Permission | admin.field_engineers |
| Inertia Page | Pages/Engineer/Tasks.jsx |
| React Components | TaskTable, TaskStatusBadge |
Business Logic: Lists engineer_tasks for this engineer's team. Tasks linked to tickets or standalone installation/fault jobs.
20. Customer Portal Group¶
Routes in routes/portal.php. Guard: portal. Prefix: /portal.
GET /portal/login¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /portal/login |
| Controller | PortalAuthController@showLogin |
| Middleware | web (no portal auth) |
| Permission | Public |
| Inertia Page | Pages/Auth/PortalLogin.jsx |
POST /portal/login¶
| Field | Value |
|---|---|
| Method | POST |
| URI | /portal/login |
| Controller | PortalAuthController@login |
| Middleware | web, throttle:5,1 |
| Permission | Public |
| Inertia Page | Redirects to /portal/dashboard |
Request Parameters: username (pppoe_username or phone), password
Business Logic:
1. Auth::guard('portal')->attempt([...]) — authenticates against customers table (portal_password field)
2. On success: session under portal guard namespace
3. Session never shares space with admin sessions
GET /portal/dashboard¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /portal/dashboard |
| Controller | PortalController@dashboard |
| Middleware | auth:portal |
| Permission | Authenticated portal user |
| Inertia Page | Pages/Portal/Dashboard.jsx |
| React Components | AccountStatusCard, ExpiryCountdown, RecentInvoices, SupportTicketButton |
Business Logic:
1. Auth::guard('portal')->user() → returns the Customer model
2. Load: package name, expiry date, current status, outstanding balance, last payment
3. All data scoped strictly to customer->id — no other customer's data is accessible
GET /portal/invoices¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /portal/invoices |
| Controller | PortalController@invoices |
| Middleware | auth:portal |
| Permission | Portal user |
| Inertia Page | Pages/Portal/Invoices.jsx |
Business Logic: Invoice::where('customer_id', $portalUser->id)->latest()->paginate(10)
GET /portal/profile¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /portal/profile |
| Controller | PortalController@profile |
| Middleware | auth:portal |
| Permission | Portal user |
| Inertia Page | Pages/Portal/Profile.jsx |
| React Components | ProfileForm, PasswordChangeForm |
Business Logic: Customer can update: phone, email, portal password. Cannot change pppoe_username, package, expiry — those are admin-only.
21. Mobile API Group¶
Prefix: /api/mobile — Auth: Sanctum token auth. All routes: throttle:60,1 unless noted.
POST /api/mobile/login¶
| Field | Value |
|---|---|
| Method | POST |
| URI | /api/mobile/login |
| Controller | MobileAuthController@login |
| Middleware | throttle:5,1 |
| Permission | Public |
Request Parameters: email, password, device_name
Business Logic:
1. Auth::attempt([...]) against users table
2. On success: $user->createToken($device_name)->plainTextToken → Sanctum personal access token
3. Return: {"token": "...", "user": {...}, "permissions": {...}}
4. Token stored in personal_access_tokens table
GET /api/mobile/me¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /api/mobile/me |
| Controller | MobileAuthController@me |
| Middleware | auth:sanctum |
| Permission | Authenticated |
Returns: User profile, role, permissions JSONB, assigned dealer info
POST /api/mobile/logout¶
| Field | Value |
|---|---|
| Method | POST |
| URI | /api/mobile/logout |
| Controller | MobileAuthController@logout |
| Middleware | auth:sanctum |
| Permission | Authenticated |
Business Logic: $request->user()->currentAccessToken()->delete() — revokes current Sanctum token only (not all tokens).
GET /api/mobile/dashboard¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /api/mobile/dashboard |
| Controller | MobileDashboardController@index |
| Middleware | auth:sanctum |
| Permission | All authenticated mobile users |
Returns: JSON: {total_customers, active, expired, online_now, monthly_revenue, outstanding}
GET /api/mobile/customers¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /api/mobile/customers |
| Controller | MobileCustomerController@index |
| Middleware | auth:sanctum |
| Permission | customers.view |
Request Parameters: page, per_page, search, status, package_id
Returns: Paginated customer list (dealer-scoped)
POST /api/mobile/customers¶
| Field | Value |
|---|---|
| Method | POST |
| URI | /api/mobile/customers |
| Controller | MobileCustomerController@store |
| Middleware | auth:sanctum |
| Permission | customers.create |
Business Logic: Same as web CustomerController@store — full RADIUS sync.
GET /api/mobile/customers/search¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /api/mobile/customers/search |
| Controller | MobileCustomerController@search |
| Middleware | auth:sanctum |
| Permission | customers.view |
Request Parameters: q — search term
Returns: Array of matching customers (dealer-scoped, max 20 results)
GET /api/mobile/customers/{id}¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /api/mobile/customers/{id} |
| Controller | MobileCustomerController@show |
| Middleware | auth:sanctum |
| Permission | customers.view |
Returns: Full customer detail JSON + RADIUS status + recent invoices
POST /api/mobile/customers/{id}/recharge¶
| Field | Value |
|---|---|
| Method | POST |
| URI | /api/mobile/customers/{id}/recharge |
| Controller | MobileCustomerController@recharge |
| Middleware | auth:sanctum, throttle:10,1 |
| Permission | customers.recharge |
Business Logic: Same as web recharge — BillingService + RADIUS sync + WhatsApp confirmation.
POST /api/mobile/customers/{id}/extend¶
| Field | Value |
|---|---|
| Method | POST |
| URI | /api/mobile/customers/{id}/extend |
| Controller | MobileCustomerController@extend |
| Middleware | auth:sanctum |
| Permission | customers.extend |
Business Logic: Extend expiry without generating a new invoice — advance expiry_date only. Used for grace extensions. RADIUS sync updated.
POST /api/mobile/customers/{id}/toggle¶
| Field | Value |
|---|---|
| Method | POST |
| URI | /api/mobile/customers/{id}/toggle |
| Controller | MobileCustomerController@toggle |
| Middleware | auth:sanctum |
| Permission | customers.status |
Business Logic: Toggle customer status between active and suspended. RadiusService::syncCustomer() updates radcheck Auth-Type attribute accordingly.
POST /api/mobile/customers/{id}/package¶
| Field | Value |
|---|---|
| Method | POST |
| URI | /api/mobile/customers/{id}/package |
| Controller | MobileCustomerController@changePackage |
| Middleware | auth:sanctum |
| Permission | customers.edit |
Request Parameters: package_id
Business Logic: Change customer->package_id. RadiusService::syncCustomer() updates radusergroup so next authentication uses the new speed group.
GET /api/mobile/customers/{id}/live-traffic¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /api/mobile/customers/{id}/live-traffic |
| Controller | MobileCustomerController@liveTraffic |
| Middleware | auth:sanctum |
| Permission | network.graphs |
Business Logic: MikroTikService::liveTraffic($customer) → connects to customer's NAS MikroTik API → fetches current bandwidth usage for this PPPoE user via /ppp/active + /interface/monitor-traffic. Returns real-time bytes_in/bytes_out per second.
GET /api/mobile/customers/{id}/traffic-history¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /api/mobile/customers/{id}/traffic-history |
| Controller | MobileCustomerController@trafficHistory |
| Middleware | auth:sanctum |
| Permission | network.graphs |
Request Parameters: days (default 7, max 90)
Returns: Daily aggregated upload/download bytes from radacct
GET /api/mobile/tickets¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /api/mobile/tickets |
| Controller | MobileTicketController@index |
| Middleware | auth:sanctum |
| Permission | tickets.view |
Returns: Paginated tickets (dealer-scoped)
GET /api/mobile/packages¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /api/mobile/packages |
| Controller | MobilePackageController@index |
| Middleware | auth:sanctum |
| Permission | All authenticated |
Returns: List of packages available to this user (dealer-filtered via dealer_package)
GET /api/mobile/reports¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /api/mobile/reports |
| Controller | MobileReportController@index |
| Middleware | auth:sanctum |
| Permission | reports.view |
Returns: Summary report JSON: revenue, collections, defaulters count
GET /api/mobile/network¶
| Field | Value |
|---|---|
| Method | GET |
| URI | /api/mobile/network |
| Controller | MobileNetworkController@index |
| Middleware | auth:sanctum |
| Permission | network.nas_view |
Returns: NAS list with session counts, online customer count per NAS
22. Internal Ajax API Group¶
Routes in routes/api.php. Session-cookie auth (Sanctum stateful). No API token required for web frontend AJAX.
| Route | Controller | Notes |
|---|---|---|
GET /api/customers |
Api\CustomerController@search |
Typeahead search, returns 10 results |
GET /api/customers/filter |
Api\CustomerController@filter |
Advanced filter — paginated JSON |
GET /api/customers/{id}/ledger |
Api\CustomerController@ledger |
Ledger entries JSON for customer detail tabs |
GET /api/nas/{id}/traffic |
Api\NasController@traffic |
MikroTik live traffic (polled by chart) |
GET /api/nas/{id}/online |
Api\NasController@online |
Active sessions on NAS |
GET /api/dashboard/online |
Api\DashboardController@online |
Online count from Redis cache |
GET /api/packages/{id}/availability |
Api\PackageController@availability |
{available: bool, count: N} |
GET /api/whatsapp/outbox |
Api\WhatsAppController@outbox |
Outbox JSON for outbox page polling |
GET /api/invoices |
Api\InvoiceController@search |
Invoice typeahead for payment forms |
GET /api/radius/{username}/status |
Api\RadiusController@status |
Current radcheck + radacct status JSON |
GET /api/health/ping |
Api\HealthController@ping |
Returns {"status":"ok"} — monitoring endpoint |
23. Route Statistics¶
| Metric | Count |
|---|---|
| Total web routes (routes/web.php + routes/auth.php) | ~95 |
| Total portal routes (routes/portal.php) | ~10 |
| Total internal AJAX API routes (routes/api.php) | ~12 |
| Total mobile API routes (/api/mobile/*) | ~20 |
| Grand total | ~137 |
Routes requiring super_admin or admin only |
~35 |
| Routes with per-permission JSONB check | ~60 |
| Routes with rate limiting (throttle middleware) | ~8 |
| Routes that trigger RADIUS sync | ~10 |
| Routes that trigger WhatsApp | ~4 |
| Routes with dealer scope enforcement | ~70 |
Routes Requiring Admin Only (selected)¶
- All
/settings/*routes - All
/users/*routes /billing/archive(POST/PUT)/import/*routes/audit/*routes/health/cron(POST/PUT/DELETE)/packages(POST/PUT/DELETE)/nas(POST/PUT/DELETE)/olt(POST/PUT/DELETE)/tr069/*/provision
Routes With Rate Limiting¶
POST /login— throttle:5,1 (5 per minute per IP+email)POST /portal/login— throttle:5,1POST /api/mobile/login— throttle:5,1POST /api/mobile/customers/{id}/recharge— throttle:10,1GET /api/nas/{id}/traffic— throttle:30,1 (to protect MikroTik API)
Cross-reference: For service implementations see 08_Backend_Architecture.md. For RADIUS sync details see 11_Radius_System.md. For billing flow see 10_Billing_System.md. For the permission system see PHASE2_02_Permission_Matrix.md.