👥 Client Portal — Business Guide Engineering Services Platform • Read-only project visibility for clients
🌟

System Overview & Actor Model

CLI-001

The 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.

🔒 Strict Data Isolation by 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.

PLATFORM ADMIN Super user BUSINESS ADMIN Manages projects EMPLOYEE Uploads content CONTRACTOR Site work CLIENT user_type = 'client' Project owner / paying party ● READ-ONLY DATA ISOLATION BOUNDARY (client_user_id scope) Project WHERE client_user_id = auth()->id() ProjectPhase sort_order asc ProjectDocument status='published' GalleryItem visibility='client' LedgerEntry party_type='client' SupportTicket client_user_id

Client Capabilities Summary

📋
Project List
View all projects assigned to this client account. Read-only metadata including status, location, contract value.
GET /api/client/projects
📈
Phase Progress
Track completion percentage per phase across all assigned projects. Admin-controlled values.
GET /api/client/progress
📄
Published Documents
Download drawings, contracts, reports — only after admin publishes them. Drafts and pending docs are invisible.
GET /api/client/documents
📷
Site Gallery
View site photographs with visibility='client'. Internal photos are never exposed.
GET /api/client/gallery
💬
Support Tickets
Raise and view support tickets linked optionally to a project. Cannot close or reassign — admin handles resolution.
POST /GET /api/client/support
💲
Financial Ledger
View invoices, payments, and balance due. Full debit/credit ledger filtered to this client only.
GET /api/client/ledger
🔔
Notifications
Platform notifications addressed to the authenticated client user — project updates, document releases, ticket replies.
GET /api/client/notifications
📐
Dashboard KPIs
Summary counts and a personalised welcome message on login. Minimal aggregate — clients navigate from project list.
GET /api/client/dashboard

What Clients Cannot Do

ActionReasonWho Does It
Create or update project recordsNo write routes exist in client controllerBusiness Admin
Upload documents or gallery imagesUpload is employee/admin function onlyEmployee / Admin
Update phase progress percentagesAdmin-only fieldBusiness Admin
Close or reassign support ticketsNo status-transition endpoint for clientBusiness Admin
Add ledger entriesLedger is admin-managedBusiness Admin
Access other clients' dataHard-scoped by client_user_id=auth()->id()N/A — impossible
Access admin, employee, or contractor APIsrejectUnlessPortalUserType guardN/A — 403 returned
🔐

Authentication & Access Model

CLI-002

Client 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.

No role/permission system for clients. Unlike the admin portal (which uses granular roles and permissions), the client portal is binary: you are either a client user with access to your assigned projects, or you are not. There is no “client admin” vs “client read-only” distinction — all clients have the same capability set.

Authentication Flow

1
CLIENT
POST /api/login with body {"email":"client@example.com","password":"...","user_type":"client"}. The user_type field routes the request to client credential validation.
2
SYSTEM
Auth service verifies credentials against the 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.
3
APP
Mobile/web app stores the Bearer token securely (e.g., encrypted local storage or keychain). Attaches it as Authorization: Bearer {token} on every subsequent request.
4
SYSTEM
On each protected endpoint, 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 PrefixClient Can Access?Enforcement
/api/client/*✅ Yes — own data onlyrejectUnlessPortalUserType('client') + client_user_id scope
/api/admin/*❌ NorejectUnlessPortalUserType('admin') → 403
/api/employee/*❌ NorejectUnlessPortalUserType('employee') → 403
/api/contractor/*❌ NorejectUnlessPortalUserType('contractor') → 403
/api/platform-admin/*❌ NoPlatform admin guard → 403
Other clients' project data❌ No — impossibleclient_user_id = auth()->id() in every query

Token Lifecycle

EventAction Required
Token expiryRe-authenticate via POST /api/login — API returns 401
Password change by adminExisting tokens may be invalidated depending on configuration — re-login required
Account deactivatedAll tokens rejected — 401 on all endpoints
LogoutPOST /api/logout — token revoked server-side
📐

Dashboard KPIs

CLI-003

The 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

FieldTypeSource / Logic
project_countintegerProject::where('client_user_id', auth()->id())->count()
welcome_messagestringPersonalised greeting, typically includes client's name from auth()->user()->name

Query Logic

// ClientDashboardController@dashboard() project_count = Project ::where('client_user_id', auth()->id()) ->count(); // Returns only projects explicitly assigned to this client // No status filter — counts ALL project statuses (active, on_hold, completed, etc.) welcome_message = "Welcome back, {auth()->user()->name}";
No status filter on project_count. The dashboard count includes projects in all statuses: 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.
Example Response
project_count3
welcome_message"Welcome back, Mr. Ahmed Khan"
Dashboard is a landing screen, not a reporting tool. Do not build business logic on top of project_count alone. For accurate project status breakdowns, iterate over GET /api/client/projects and aggregate client-side.
📋

Project View

CLI-004

The 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.

// ClientDashboardController@projects() Project::where('client_user_id', auth()->id()) ->orderByDesc('id') ->get(); // One client may appear on multiple projects // No business_id scoping — client_user_id alone determines visibility

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.

1
ADMIN
Creates or edits a project. Sets client_user_id to the ID of the client user account (e.g., obtained from the users list).
2
SYSTEM
Stores client_user_id on the projects row. No invitation email or notification is guaranteed by default — depends on platform notification configuration.
3
CLIENT
Calls GET /api/client/projects — the newly linked project now appears in the response list.

Visible Project Fields

FieldTypeNotes
idintegerPrimary key — used to scope phases, docs, gallery, ledger queries
titlestringHuman-readable project name (e.g., "Residential Block A — Foundation")
descriptiontextFree-text project scope description set by admin
statusenumSee status state machine below
locationstringSite address or coordinates
contract_valuedecimalTotal contract amount — informational only for client
client_user_idinteger (FK)The auth user ID — always matches auth()->id() for this client

Project Status State Machine

draft active on_hold active completed
draft / active / on_hold cancelled
StatusMeaning for Client
draftProject set up but not yet commenced — may appear in list but no active phases
activeWork in progress — phases updating, gallery growing, documents being published
on_holdTemporarily paused — progress frozen, admin explanation typically in support ticket
completedAll phases done, final documents published, ledger settled
cancelledProject terminated — historical data remains visible to client
Read-only constraint. The client API exposes no PATCH, PUT, or DELETE routes for project records. Clients cannot rename a project, change its status, or update any field. All project lifecycle management is performed exclusively by business administrators.
📈

Phase Progress Tracking

CLI-005

Phase 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.

// ClientDashboardController@progress() project_ids = Project::where('client_user_id', auth()->id()) ->pluck('id'); // e.g. [12, 47, 93] ProjectPhase::whereIn('project_id', project_ids) ->orderBy('sort_order') ->get();

Phase Fields

FieldTypeNotes
idintegerPhase primary key
project_idinteger (FK)Parent project reference
namestringPhase label (e.g., "Foundation Work", "Structural Frame")
descriptiontextScope of work for this phase
progress_percentinteger (0–100)Admin-updated completion percentage
sort_orderintegerDisplay order within a project (ascending = first shown)
statusenumpending / in_progress / completed
start_datedatePlanned or actual start
end_datedatePlanned 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:

// Approach A — Simple average (equal weight per phase) simple_avg = SUM(progress_percent) / COUNT(phases) // Approach B — Weighted average (if phases carry a weight field) weighted_avg = SUM(progress_percent * weight) / SUM(weight) // where weight defaults to 1 if no weight field is present // Example with 4 phases: 100%, 75%, 50%, 0% simple_avg = (100 + 75 + 50 + 0) / 4 = 56.25%
Example — Residential Block A (6 Phases)
Phase 1: Site Preparation100%
Phase 2: Foundation100%
Phase 3: Structural Frame60%
Phase 4: Brickwork & Masonry20%
Phase 5: MEP Rough-In0%
Phase 6: Finishing & Handover0%
Overall (simple avg)(100+100+60+20+0+0)/6 = 46.7%
Progress is entirely admin-controlled. Clients have no endpoint to update 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-006

Documents 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.

// ClientDashboardController@documents() project_ids = Project::where('client_user_id', auth()->id()) ->pluck('id'); ProjectDocument::whereIn('project_id', project_ids) ->where('status', 'published') // ← hard filter ->orderByDesc('id') ->get();

Document Status Visibility Matrix

StatusClient Sees?Admin Use / Meaning
draft❌ NoDocument is being authored or assembled — not ready for any review
pending❌ NoSubmitted for internal review — awaiting approval decision
approved❌ NoInternally approved but intentionally not yet shared with client — staging step
publishedYesExplicitly released to client — visible and downloadable in client portal
rejected❌ NoInternal rejection — document needs rework; never exposed to client

Document Status Lifecycle

draft pending approved published
pending / approved rejected draft (rework)

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)

1
ADMIN
Creates a ProjectDocument record with status='draft'. Uploads the file (drawing, contract, report). Associates it with the project and sets its document_type_id.
2
ADMIN
Submits for review by transitioning document to status='pending'. If DocumentType.requires_approval = false, can skip directly to publish.
3
ADMIN
Reviewer (senior admin or principal engineer) approves the document: status → 'approved'. Or rejects: status → 'rejected' sending it back to the drafter.
4
ADMIN
Final publish decision: transitions document to 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.
5
CLIENT
Calls GET /api/client/documents. The published document now appears in the response. Client can download using the document URL from the payload.
“Approved” does not mean “visible to client.” A common misconception is that once a document is internally approved, the client can see it. They cannot. The explicit publish action (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-007

The 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.

// ClientDashboardController@gallery() project_ids = Project::where('client_user_id', auth()->id()) ->pluck('id'); GalleryItem::whereIn('project_id', project_ids) ->where('visibility', 'client') // ← hard filter ->orderByDesc('id') ->get();

Gallery Visibility Levels

Visibility ValueWho Sees ItTypical Use Case
internalAdmin & Employees onlyConstruction defects, internal progress tracking, contractor coordination photos
clientClient (+ admin & employees)Progress milestones the business is comfortable sharing — foundation poured, frame erected, etc.
publicAnyone (public website / marketing)Completed project showcase photos, portfolio imagery
The 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

1
EMPLOYEE
Site engineer or supervisor uploads photos via the employee portal (POST /api/employee/gallery). New items default to visibility='internal'. The client cannot see them yet.
2
ADMIN
Reviews uploaded photos. Selects progress shots suitable for client sharing. Updates each approved item: PATCH /api/admin/gallery/{id} setting visibility='client'. Optionally adds a caption and date stamp.
3
CLIENT
Calls 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

FieldTypeNotes
idintegerItem primary key
project_idinteger (FK)Parent project
titlestringImage caption or label
file_urlstringFull URL to image file (S3 or local storage)
visibilityenuminternal | client | public — always 'client' in this response
taken_atdatetimeWhen the photo was taken (site date)
uploaded_byinteger (FK)Employee user ID who uploaded
💬

Support Ticket Lifecycle

CLI-008

Support 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

// ClientDashboardController@supportStore() // Validation rules: subject → required | string | max:255 message → required | string project_id → nullable | integer // link to a specific project (optional) // Created record: SupportTicket::create([ 'business_id' => /* resolved from context */, 'client_user_id' => auth()->id(), 'project_id' => request->project_id, // nullable 'subject' => request->subject, 'message' => request->message, 'status' => 'open', // always starts open ]);

Ticket Status State Machine

open in_progress closed
open cancelled
StatusSet ByMeaning
openSystem (auto on create)Newly raised — awaiting admin triage
in_progressAdmin onlyAdmin is actively working on the issue or has acknowledged it
closedAdmin onlyIssue resolved — client may view final admin response if recorded
cancelledAdmin onlyTicket raised in error or duplicate — administratively cancelled

Support Ticket Lifecycle Flow

1
CLIENT
Composes a ticket: POST /api/client/support with subject, message, and optionally project_id. Example: subject="Crack observed on north wall", project_id=12.
2
SYSTEM
Validates fields. Creates SupportTicket with status='open', records client_user_id, business_id, and project_id. Returns the new ticket record with its ID.
3
ADMIN
Reviews the ticket via admin portal. Assigns it internally, investigates the reported issue. Transitions ticket to in_progress. Optionally adds a response message or internal note.
4
CLIENT
Polls GET /api/client/support to see updated ticket status. Can see when status changed from open to in_progress. Cannot change it themselves.
5
ADMIN
Upon resolution, transitions ticket to closed. Records resolution summary. Client can view the closed status and any response text on their next GET /api/client/support call.

Validation Errors

FieldRuleError if violated
subjectrequired, string, max 255 chars422 — "The subject field is required" or "The subject may not be greater than 255 characters"
messagerequired, string422 — "The message field is required"
project_idnullable, integer422 — "The project id must be an integer" (if provided but invalid)
Admin must handle all ticket resolution. There is no 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-009

The 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.

// ClientDashboardController@ledger() LedgerEntry::where('party_type', 'client') ->where('party_id', auth()->id()) ->orderByDesc('entry_date') ->get(); // party_type='client' ensures no ledger entries from other party types // (e.g. contractor or supplier ledger entries) leak into this response

Entry Types & Debit/Credit Convention

entry_typeDebit SideCredit SideBusiness Meaning
invoiceamountBusiness issued an invoice — client now owes this amount
paymentamountClient made a payment — reduces the balance outstanding
refundamountBusiness returned money to client (e.g. overpayment correction)
adjustmentvariesvariesManual correction — debit or credit depending on nature of adjustment
client_receiptamountFormal receipt confirmed for a client payment — similar to 'payment' but receipt-issued

Balance Due Formula

// Computing balance due from raw ledger entries: total_invoiced = SUM(amount) WHERE entry_type = 'invoice' total_refunded = SUM(amount) WHERE entry_type = 'refund' total_paid = SUM(amount) WHERE entry_type IN ('payment', 'client_receipt') balance_due = total_invoiced - total_paid + total_refunded // positive → client still owes money to the business // zero → account settled // negative → client has overpaid (credit balance in their favour) // Note: 'adjustment' entries must be applied selectively // based on whether debit_amount or credit_amount is set

Worked Example — Mr. Ahmed Khan (Residential Block A)

Ledger Entries (PKR)
Invoice #INV-001Credit PKR 500,000  (Mobilization advance)
Invoice #INV-002Credit PKR 750,000  (Phase 1 completion)
Payment #PAY-001Debit  PKR 500,000  (Client bank transfer)
total_invoicedPKR 1,250,000
total_paidPKR 500,000
balance_duePKR 750,000 outstanding ⚠

LedgerEntry Fields

FieldTypeNotes
idintegerPrimary key
party_typestringAlways 'client' in this response
party_idintegerEquals auth()->id()
entry_typeenuminvoice / payment / refund / adjustment / client_receipt
amountdecimalTransaction amount
descriptiontextHuman-readable description of what this entry represents
entry_datedateDate the entry was recorded (used for ordering)
referencestringInvoice number, receipt number, or transaction reference
project_idinteger (FK, nullable)Optional link to a specific project (useful for multi-project clients)
Multi-project clients. A client assigned to multiple projects will see ledger entries from all their projects in a single response (filtered only by 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-010

This 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.

Scenario context: Mr. Ahmed Khan is a private client commissioning “Residential Block A” — a 4-storey residential building with a total contract value of PKR 2,500,000. The engineering firm uses this platform to manage project delivery, billing, and client communication.
1
Platform Admin Onboards Engineering Business SYSTEM
The platform super-admin creates the engineering firm’s business account and provisions a business admin user. The business is assigned a business_id that will be stamped on all subsequently created records (projects, tickets, ledger entries).
2
Business Admin Creates Client User Account ADMIN
Business admin creates a user record for Mr. Ahmed Khan with 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.
3
Admin Creates Project & Links Client ADMIN
Admin creates a new 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.
4
Mr. Ahmed Logs In & Views Dashboard CLIENT
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”.
5
Admin Creates 6 Project Phases ADMIN
Admin creates phases in the admin portal: Site Preparation, Foundation, Structural Frame, Brickwork & Masonry, MEP Rough-In, Finishing & Handover. All phases start at 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.
6
Admin Posts Mobilization Invoice ADMIN
Admin creates a 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'.
7
Mr. Ahmed Views Ledger — Sees Balance Due CLIENT
Calls 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.
8
Admin Publishes 3 Project Documents ADMIN
Admin uploads and goes through the approval workflow for: (1) Architectural Drawings Rev.1, (2) Signed Contract Agreement, (3) Structural Calculations Report. All three transition through draft → pending → approved → published. Mr. Ahmed calls GET /api/client/documents and can now download all three files.
9
Admin Updates Phase 1 Progress to 100% ADMIN
Site Preparation phase is complete. Admin updates: 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.
10
Admin Curates Gallery — 5 Site Photos Published ADMIN EMP
Site engineer uploads 12 photos via employee portal (all default to 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.
11
Mr. Ahmed Raises Support Ticket CLIENT
During a site visit, Mr. Ahmed notices a hairline crack on the north retaining wall. Raises a ticket: 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'.
12
Admin Investigates & Closes Ticket ADMIN
Structural engineer reviews the crack. Admin transitions ticket to 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-011

These are the most common HTTP error responses a client app will encounter. All errors follow the standard JSON error envelope: {"message": "...", "errors": {...}}.

HTTP CodeTrigger ConditionResolution
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

// 422 response body example for support ticket creation: { "message": "The subject field is required. (and 1 more error)", "errors": { "subject": ["The subject field is required."], "message": ["The message field is required."] } }

Common Mistakes & Fixes

SymptomLikely CauseFix
Project list returns empty array []Admin has not yet set client_user_id on any projectAsk business admin to link the client account to a project
Documents endpoint returns empty arrayDocuments exist but none are in published statusAsk admin to publish documents — drafts/approved docs are not visible to client
Gallery returns empty arrayGallery items exist but all have visibility='internal'Ask admin to change visibility of selected photos to client
Progress shows 0% on all phasesAdmin has not yet updated progress_percent on any phaseNo action by client — admin must update phase progress as work advances
Ledger shows no entriesNo LedgerEntry records created yet for this clientNo action by client — admin creates all ledger entries
Support ticket creation returns 422Missing subject or message field in POST bodyInclude both required fields in the JSON request body
🔔

Notifications

CLI-012

The 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

MethodPathPurposeAuth guard
GET/api/client/notificationsBell dropdown — ≤20 most-recent unread notificationsuser_type ∈ {client, customer}
PATCH/api/client/notifications/read-allMark every unread notification as read for the authenticated clientuser_type ∈ {client, customer}

Bell Dropdown Query

-- GET /api/client/notifications
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)

FieldTypeDescription
idintegerNotification PK — determines DESC sort order
typestringMachine-readable event type, e.g. project_updated, document_published
titlestringShort notification headline for display
bodystringFull notification message (empty string if null)
is_readbooleanAlways false in bell dropdown (only unread rows returned)
dataobjectArbitrary JSON payload — e.g. { "project_id": 7 }; empty object if null
created_atstring (ISO 8601)When the notification was created

Common Notification Triggers

Trigger Eventtype valueExample Title
Admin updates project statusproject_updated"Your project status changed to: In Progress"
Admin publishes a document for the clientdocument_published"New document available: Structural Report v2"
Admin approves a proposal for the clientproposal_approved"Your proposal has been approved — work begins 2026-07-10"
Admin adds new gallery photosgallery_updated"New site photos available for Block A — Roofing phase"
Support ticket response from adminsupport_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"
ℹ Scoping: 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.
Example GET /api/client/notifications Response
successtrue
msg"Notifications"
data[0].id408
data[0].type"document_published"
data[0].title"New document available: Structural Report v2"
data[0].body"The structural engineering report for Block A has been published."
data[0].is_readfalse
data[0].data{ "project_id": 7, "document_id": 21 }
data[0].created_at"2026-07-03T10:05:47.000000Z"