System Overview & Actor Model
CLI-001The Client Portal is the outward-facing window of the engineering services platform. It gives project owners — the paying parties who commission civil/structural/MEP work — a real-time view of their project’s progress, documentation, gallery, financials, and a direct line to raise support tickets. Unlike the admin, employee, or contractor portals, the client portal is strictly read-only for all project data. Clients observe; administrators act.
client_user_id
Every data query in ClientDashboardController is scoped to client_user_id = auth()->id() on the Project model. There is no cross-client leakage — a client user can only ever see projects explicitly assigned to them by an administrator. All derived data (phases, documents, gallery, ledger entries) flows through the project ID set owned by that client.
Portal Ecosystem — Actor Map
The platform hosts five distinct portal roles. The client is highlighted below; all others are dimmed to show the isolation boundary.
Client Capabilities Summary
visibility='client'. Internal photos are never exposed.What Clients Cannot Do
| Action | Reason | Who Does It |
|---|---|---|
| Create or update project records | No write routes exist in client controller | Business Admin |
| Upload documents or gallery images | Upload is employee/admin function only | Employee / Admin |
| Update phase progress percentages | Admin-only field | Business Admin |
| Close or reassign support tickets | No status-transition endpoint for client | Business Admin |
| Add ledger entries | Ledger is admin-managed | Business Admin |
| Access other clients' data | Hard-scoped by client_user_id=auth()->id() | N/A — impossible |
| Access admin, employee, or contractor APIs | rejectUnlessPortalUserType guard | N/A — 403 returned |
Authentication & Access Model
CLI-002Client authentication uses the same token-based mechanism as other portals. The critical differentiator is the user_type field: every endpoint in ClientDashboardController calls rejectUnlessPortalUserType('client') as its first action. If the authenticated user has any other user_type, a 403 Forbidden is returned immediately.
Authentication Flow
/api/login with body {"email":"client@example.com","password":"...","user_type":"client"}. The user_type field routes the request to client credential validation.users table. Confirms user_type = 'client' and that the account is active. If valid, issues a Bearer token (Sanctum or Passport). Token is tied to this specific user ID.Authorization: Bearer {token} on every subsequent request.rejectUnlessPortalUserType('client') verifies token validity and user_type. Resolves auth()->id() for all subsequent data scoping queries. Returns 401 if token is invalid/expired, 403 if user_type mismatch.Access Boundaries
| Portal / API Prefix | Client Can Access? | Enforcement |
|---|---|---|
/api/client/* | ✅ Yes — own data only | rejectUnlessPortalUserType('client') + client_user_id scope |
/api/admin/* | ❌ No | rejectUnlessPortalUserType('admin') → 403 |
/api/employee/* | ❌ No | rejectUnlessPortalUserType('employee') → 403 |
/api/contractor/* | ❌ No | rejectUnlessPortalUserType('contractor') → 403 |
/api/platform-admin/* | ❌ No | Platform admin guard → 403 |
| Other clients' project data | ❌ No — impossible | client_user_id = auth()->id() in every query |
Token Lifecycle
| Event | Action Required |
|---|---|
| Token expiry | Re-authenticate via POST /api/login — API returns 401 |
| Password change by admin | Existing tokens may be invalidated depending on configuration — re-login required |
| Account deactivated | All tokens rejected — 401 on all endpoints |
| Logout | POST /api/logout — token revoked server-side |
Dashboard KPIs
CLI-003The client dashboard (GET /api/client/dashboard) is intentionally minimal. Clients are project-centric users, not aggregate analysts. The dashboard exists to give an instant orientation — how many projects are active — with a personalised welcome message. The real work happens in the project list and derived views.
Returned Data
| Field | Type | Source / Logic |
|---|---|---|
project_count | integer | Project::where('client_user_id', auth()->id())->count() |
welcome_message | string | Personalised greeting, typically includes client's name from auth()->user()->name |
Query Logic
draft, active, on_hold, completed, and cancelled. If your app needs an “active projects” count, apply a status filter client-side after fetching the project list from GET /api/client/projects.
project_count alone. For accurate project status breakdowns, iterate over GET /api/client/projects and aggregate client-side.
Project View
CLI-004The project list is the primary navigation hub for client users. GET /api/client/projects returns every project where client_user_id = auth()->id(), ordered by descending ID (most recently created first). From here, clients navigate to phases, documents, gallery, and ledger entries — all of which are scoped to the same project ID set.
Project Linking
A business admin links a client to a project by setting client_user_id on the project record at creation or update time. The client has no ability to self-associate with a project — an admin action is always required first.
client_user_id to the ID of the client user account (e.g., obtained from the users list).client_user_id on the projects row. No invitation email or notification is guaranteed by default — depends on platform notification configuration.GET /api/client/projects — the newly linked project now appears in the response list.Visible Project Fields
| Field | Type | Notes |
|---|---|---|
id | integer | Primary key — used to scope phases, docs, gallery, ledger queries |
title | string | Human-readable project name (e.g., "Residential Block A — Foundation") |
description | text | Free-text project scope description set by admin |
status | enum | See status state machine below |
location | string | Site address or coordinates |
contract_value | decimal | Total contract amount — informational only for client |
client_user_id | integer (FK) | The auth user ID — always matches auth()->id() for this client |
Project Status State Machine
| Status | Meaning for Client |
|---|---|
draft | Project set up but not yet commenced — may appear in list but no active phases |
active | Work in progress — phases updating, gallery growing, documents being published |
on_hold | Temporarily paused — progress frozen, admin explanation typically in support ticket |
completed | All phases done, final documents published, ledger settled |
cancelled | Project terminated — historical data remains visible to client |
Phase Progress Tracking
CLI-005Phase progress gives clients real-time visibility into how far along each stage of their project has advanced. The endpoint returns ProjectPhase records across all projects assigned to the client, ordered by sort_order — allowing the mobile app to render a unified timeline or per-project breakdown.
Phase Fields
| Field | Type | Notes |
|---|---|---|
id | integer | Phase primary key |
project_id | integer (FK) | Parent project reference |
name | string | Phase label (e.g., "Foundation Work", "Structural Frame") |
description | text | Scope of work for this phase |
progress_percent | integer (0–100) | Admin-updated completion percentage |
sort_order | integer | Display order within a project (ascending = first shown) |
status | enum | pending / in_progress / completed |
start_date | date | Planned or actual start |
end_date | date | Planned or actual end |
Computing Overall Progress
The API returns raw phase records. Computing an aggregate "overall project completion" percentage is the app’s responsibility. Two common approaches:
progress_percent. When a client reports that their project is progressing faster or slower than shown, the correct resolution is for the business admin to update the phase record — there is no client-facing correction flow.
Document Visibility Rules
CLI-006Documents are the most access-controlled resource in the client portal. The rule is strict and simple: only documents with status='published' are ever visible to a client. Documents in any other status — including internally approved documents — are completely invisible. This protects the business from accidentally sharing draft drawings, unapproved cost estimates, or sensitive internal reports.
Document Status Visibility Matrix
| Status | Client Sees? | Admin Use / Meaning |
|---|---|---|
draft | ❌ No | Document is being authored or assembled — not ready for any review |
pending | ❌ No | Submitted for internal review — awaiting approval decision |
approved | ❌ No | Internally approved but intentionally not yet shared with client — staging step |
published | ✅ Yes | Explicitly released to client — visible and downloadable in client portal |
rejected | ❌ No | Internal rejection — document needs rework; never exposed to client |
Document Status Lifecycle
Approval-Gated Document Types
The DocumentType model carries a requires_approval boolean flag. When set to true, the document must pass through the pending → approved transition before it can be published. Document types without this flag may be published directly from draft by an admin with sufficient permissions.
Document Publish Flow (Approval-Gated)
ProjectDocument record with status='draft'. Uploads the file (drawing, contract, report). Associates it with the project and sets its document_type_id.status='pending'. If DocumentType.requires_approval = false, can skip directly to publish.status → 'approved'. Or rejects: status → 'rejected' sending it back to the drafter.status='published'. This is the moment the document becomes visible in the client portal. Can be timed to coincide with a project milestone or client meeting.GET /api/client/documents. The published document now appears in the response. Client can download using the document URL from the payload.status → 'published') is a separate, deliberate step. This staging window lets businesses review commercially sensitive information (e.g., contractor cost breakdowns) internally before deciding what to share.
Gallery Visibility Rules
CLI-007The gallery lets clients see site photographs and progress images. Like documents, gallery items carry a visibility attribute that controls who can see them. The client portal applies a hard filter: visibility = 'client'. Internal site photographs — useful for employee coordination but not intended for client consumption — are never exposed through the client API.
Gallery Visibility Levels
| Visibility Value | Who Sees It | Typical Use Case |
|---|---|---|
internal | Admin & Employees only | Construction defects, internal progress tracking, contractor coordination photos |
client | Client (+ admin & employees) | Progress milestones the business is comfortable sharing — foundation poured, frame erected, etc. |
public | Anyone (public website / marketing) | Completed project showcase photos, portfolio imagery |
public visibility tier means the image is appropriate for marketing use. It is also visible to clients (it is not restricted), but the client portal query filters specifically on visibility='client'. If your app wants to show public images too, the filter should be whereIn('visibility', ['client', 'public']) — but confirm with your backend whether this is supported or requires a separate endpoint.
Gallery Curation Workflow
POST /api/employee/gallery). New items default to visibility='internal'. The client cannot see them yet.PATCH /api/admin/gallery/{id} setting visibility='client'. Optionally adds a caption and date stamp.GET /api/client/gallery. The curated photos now appear. Client can browse by project, view captions, and download full-resolution images from the returned URL fields.Gallery Item Fields
| Field | Type | Notes |
|---|---|---|
id | integer | Item primary key |
project_id | integer (FK) | Parent project |
title | string | Image caption or label |
file_url | string | Full URL to image file (S3 or local storage) |
visibility | enum | internal | client | public — always 'client' in this response |
taken_at | datetime | When the photo was taken (site date) |
uploaded_by | integer (FK) | Employee user ID who uploaded |
Support Ticket Lifecycle
CLI-008Support tickets are the primary communication channel between the client and the business. Clients can raise concerns, report observations, or ask questions via POST /api/client/support. The ticket system is asymmetric by design: clients create and view tickets, but all status transitions are performed exclusively by administrators.
Creating a Support Ticket
Ticket Status State Machine
| Status | Set By | Meaning |
|---|---|---|
open | System (auto on create) | Newly raised — awaiting admin triage |
in_progress | Admin only | Admin is actively working on the issue or has acknowledged it |
closed | Admin only | Issue resolved — client may view final admin response if recorded |
cancelled | Admin only | Ticket raised in error or duplicate — administratively cancelled |
Support Ticket Lifecycle Flow
POST /api/client/support with subject, message, and optionally project_id. Example: subject="Crack observed on north wall", project_id=12.SupportTicket with status='open', records client_user_id, business_id, and project_id. Returns the new ticket record with its ID.in_progress. Optionally adds a response message or internal note.GET /api/client/support to see updated ticket status. Can see when status changed from open to in_progress. Cannot change it themselves.closed. Records resolution summary. Client can view the closed status and any response text on their next GET /api/client/support call.Validation Errors
| Field | Rule | Error if violated |
|---|---|---|
subject | required, string, max 255 chars | 422 — "The subject field is required" or "The subject may not be greater than 255 characters" |
message | required, string | 422 — "The message field is required" |
project_id | nullable, integer | 422 — "The project id must be an integer" (if provided but invalid) |
PATCH /api/client/support/{id} endpoint. Clients cannot close their own tickets, update the subject, or add follow-up messages after creation. If a follow-up is needed, the client must raise a new ticket (referencing the original ticket ID in the message body as a workaround).
Financial Ledger
CLI-009The financial ledger gives clients a transparent view of their billing and payment history. Every invoice issued by the business, every payment received, and every adjustment made is recorded as a LedgerEntry and visible to the client through this endpoint. The ledger uses a standard double-entry-inspired debit/credit convention to represent money flows.
Entry Types & Debit/Credit Convention
| entry_type | Debit Side | Credit Side | Business Meaning |
|---|---|---|---|
invoice | — | amount | Business issued an invoice — client now owes this amount |
payment | amount | — | Client made a payment — reduces the balance outstanding |
refund | — | amount | Business returned money to client (e.g. overpayment correction) |
adjustment | varies | varies | Manual correction — debit or credit depending on nature of adjustment |
client_receipt | amount | — | Formal receipt confirmed for a client payment — similar to 'payment' but receipt-issued |
Balance Due Formula
Worked Example — Mr. Ahmed Khan (Residential Block A)
LedgerEntry Fields
| Field | Type | Notes |
|---|---|---|
id | integer | Primary key |
party_type | string | Always 'client' in this response |
party_id | integer | Equals auth()->id() |
entry_type | enum | invoice / payment / refund / adjustment / client_receipt |
amount | decimal | Transaction amount |
description | text | Human-readable description of what this entry represents |
entry_date | date | Date the entry was recorded (used for ordering) |
reference | string | Invoice number, receipt number, or transaction reference |
project_id | integer (FK, nullable) | Optional link to a specific project (useful for multi-project clients) |
party_id, not project_id). The project_id field on each entry allows the app to group or filter entries by project for per-project balance views.
End-to-End Client Journey
CLI-010This scenario traces the complete lifecycle of a client engagement — from platform onboarding through project completion — through the eyes of Mr. Ahmed Khan, a property owner commissioning a residential construction project. All 12 steps are grounded in the actual controller logic described in the preceding sections.
business_id that will be stamped on all subsequently created records (projects, tickets, ledger entries).user_type='client', email, and temporary password. Notifies Mr. Ahmed of his login credentials via email or phone. No projects are visible yet — the account exists but has no linked projects.Project record: title=“Residential Block A”, status='active', location=“Gulberg III, Lahore”, contract_value=PKR 2,500,000, and crucially client_user_id = Mr. Ahmed's user ID. From this moment, Mr. Ahmed’s API calls will return this project.POST /api/login with user_type='client' → receives Bearer token. Calls GET /api/client/dashboard → response: project_count=1, welcome_message=“Welcome back, Mr. Ahmed Khan”. Navigates to project list to see “Residential Block A”.progress_percent=0. Mr. Ahmed calls GET /api/client/progress and sees all 6 phases at 0% — a clear project roadmap with zero progress initially.LedgerEntry: party_type='client', party_id=Mr. Ahmed's ID, entry_type='invoice', amount=750,000, description=“Mobilization Advance — 30% of contract value”, reference='INV-2024-001'.GET /api/client/ledger. Response includes the mobilization invoice for PKR 750,000. App computes: total_invoiced=750,000, total_paid=0, balance_due=PKR 750,000. Mr. Ahmed arranges payment to the engineering firm’s account.draft → pending → approved → published. Mr. Ahmed calls GET /api/client/documents and can now download all three files.progress_percent=100, status='completed'. Mr. Ahmed calls GET /api/client/progress and now sees: Phase 1 = 100%, all others = 0%. Overall simple average = 16.7%. App renders the updated progress bar.visibility='internal'). Admin reviews and selects 5 progress milestone shots, updates each to visibility='client'. Mr. Ahmed calls GET /api/client/gallery and sees exactly these 5 photos with captions — the 7 internal photos remain hidden.POST /api/client/support with subject=“Crack observed on north retaining wall”, message=“Approximately 3mm wide, 40cm long, appears to run vertically from foundation level. Request structural review.”, project_id=<project id>. Ticket created with status='open'.in_progress while investigation runs. Engineer determines the crack is within acceptable shrinkage tolerance and non-structural. Admin adds response: “Reviewed on-site — confirmed hairline shrinkage crack, no structural implication. Will be sealed during finishing phase.” Transitions to closed. Mr. Ahmed calls GET /api/client/support and sees the updated status and resolution note — concern resolved without ambiguity.Error Reference
CLI-011These are the most common HTTP error responses a client app will encounter. All errors follow the standard JSON error envelope: {"message": "...", "errors": {...}}.
| HTTP Code | Trigger Condition | Resolution |
|---|---|---|
401 Unauthorized |
Bearer token missing from Authorization header; token expired; token revoked after logout or password change |
Re-authenticate: POST /api/login with valid credentials to obtain a new token |
403 Forbidden |
user_type of authenticated user is not 'client' — e.g., an admin token used against the client API, or vice versa |
Ensure login was performed with user_type: 'client' and that the account is a client-type user. Using the correct endpoint base path (/api/client/*) is necessary |
404 Not Found |
Requesting a resource that does not exist, or that belongs to a different client (scoping prevents access — returns 404 rather than 403 for ambiguity) | Verify the resource ID is valid and belongs to this client's project set |
422 Unprocessable Entity |
Validation failure on POST /api/client/support: subject missing or >255 chars; message missing; project_id not an integer |
Include both subject (string, max 255) and message (string) in the request body. Ensure project_id is an integer if provided |
429 Too Many Requests |
Rate limit exceeded — too many API calls in a short window | Implement exponential backoff in the app. Avoid polling on a tight loop — use reasonable intervals (>30s) for progress/notification checks |
500 Internal Server Error |
Unexpected server-side failure | Retry once after a short delay. If persistent, report to business admin with the request timestamp for server log correlation |
Validation Error Response Shape
Common Mistakes & Fixes
| Symptom | Likely Cause | Fix |
|---|---|---|
Project list returns empty array [] | Admin has not yet set client_user_id on any project | Ask business admin to link the client account to a project |
| Documents endpoint returns empty array | Documents exist but none are in published status | Ask admin to publish documents — drafts/approved docs are not visible to client |
| Gallery returns empty array | Gallery items exist but all have visibility='internal' | Ask admin to change visibility of selected photos to client |
| Progress shows 0% on all phases | Admin has not yet updated progress_percent on any phase | No action by client — admin must update phase progress as work advances |
| Ledger shows no entries | No LedgerEntry records created yet for this client | No action by client — admin creates all ledger entries |
| Support ticket creation returns 422 | Missing subject or message field in POST body | Include both required fields in the JSON request body |
Notifications
CLI-012The Client Portal exposes two notification endpoints: a bell dropdown returning the 20 most-recent unread notifications, and a bulk mark-all-read action. Notifications are generated by Admin actions (project updates, document publications, proposal changes, announcements). Clients cannot create or delete notifications.
Endpoints
| Method | Path | Purpose | Auth guard |
|---|---|---|---|
GET | /api/client/notifications | Bell dropdown — ≤20 most-recent unread notifications | user_type ∈ {client, customer} |
PATCH | /api/client/notifications/read-all | Mark every unread notification as read for the authenticated client | user_type ∈ {client, customer} |
Bell Dropdown Query
SELECT * FROM t_app_notifications
WHERE user_id = {auth_id}
AND is_read = false
ORDER BY id DESC
LIMIT 20;
-- Returns newest unread first; no pagination key in response
Response Row Shape (GET)
| Field | Type | Description |
|---|---|---|
id | integer | Notification PK — determines DESC sort order |
type | string | Machine-readable event type, e.g. project_updated, document_published |
title | string | Short notification headline for display |
body | string | Full notification message (empty string if null) |
is_read | boolean | Always false in bell dropdown (only unread rows returned) |
data | object | Arbitrary JSON payload — e.g. { "project_id": 7 }; empty object if null |
created_at | string (ISO 8601) | When the notification was created |
Common Notification Triggers
| Trigger Event | type value | Example Title |
|---|---|---|
| Admin updates project status | project_updated | "Your project status changed to: In Progress" |
| Admin publishes a document for the client | document_published | "New document available: Structural Report v2" |
| Admin approves a proposal for the client | proposal_approved | "Your proposal has been approved — work begins 2026-07-10" |
| Admin adds new gallery photos | gallery_updated | "New site photos available for Block A — Roofing phase" |
| Support ticket response from admin | support_reply | "Admin replied to your support ticket #ST-0042" |
| Ledger entry added (payment/invoice) | ledger_updated | "A new invoice has been posted to your account" |
WHERE user_id = Auth::id() is the sole isolation mechanism. Client users are scoped at the account level — no additional project-id filter is applied on notifications. A client user receives all notifications assigned to their user_id, regardless of which project triggered them.