System Overview & Actor Model
EMP-001The Employee Portal is one of five role-scoped portals in the Engineering Services platform. It is designed for field and office employees who need to clock in/out, view assigned tasks, log site observations, upload photos, and track their calendar events. Every data endpoint is scoped to the authenticated employee’s own user ID — no cross-employee or business-wide data is ever exposed through this portal.
An employee account is backed by two records: an Auth model (the login credential, user_type='employee') and a StaffProfile record that carries extended metadata such as the assigned business_id and the link to the employee’s projects and tasks. The connection between the two is the user_id foreign key on StaffProfile.
The Five-Portal Architecture
The platform partitions its user base into five distinct portal types. Each portal type is enforced at the API level using the rejectUnlessPortalUserType() guard, preventing cross-portal access even with a valid bearer token.
Data Isolation — Scoping Rules per Model
Every query executed in the Employee Portal includes a WHERE clause that restricts results to the authenticated employee. The table below lists the exact field used for scoping on each model.
| Model / Table | Isolation Field | Scope Value | Notes |
|---|---|---|---|
StaffProfile | user_id | auth()->id() | Used for assignment_count on dashboard |
EmployeeTask | assigned_user_id | auth()->id() | Tasks & open_task_count |
TimeClockEntry | user_id | auth()->id() | Clock-in/out history |
CalendarEvent | assigned_user_id | auth()->id() | Only events assigned to this employee |
SiteVisit | user_id | auth()->id() | Visits created by this employee |
GalleryItem | uploaded_by | auth()->id() | Photos uploaded by this employee |
AppNotification | user_id | auth()->id() | Notifications targeted at this employee |
StaffProfile Link — How Employees Are Connected to Businesses
The StaffProfile is the bridge between an employee’s login credentials and the business context. The setup flow is initiated by an Admin:
StaffProfile record in the Admin portal, setting name, role, business_id, and other HR fields.Auth (user) record with user_type='employee' and links it to the StaffProfile by setting staff_profiles.user_id = auth.id.user_type=employee and returns a Sanctum token. The employee’s business_id is inherited from their StaffProfile record for all subsequent queries.assignment_count pulled from StaffProfile WHERE user_id=auth_id AND business_id=employee_business_id.Authentication & Access Model
EMP-002The Employee Portal uses Laravel Sanctum bearer token authentication. A token is obtained by posting credentials to the shared login endpoint with user_type='employee'. Every subsequent request must include the token in the Authorization header.
Login Request
Content-Type: application/json
{
"email": "ali@example.com",
"password": "secret",
"user_type": "employee"
}
// Successful response:
{ "token": "1|AbCdEf...", "user": { ... } }
4-Step Authentication Flow
POST /api/login with email, password, and user_type="employee".users / auth table using Sanctum. If the email/password is invalid, returns 401 Unauthorized.user_type = 'employee'. If the user is an admin or contractor who accidentally used this endpoint, returns 403 Forbidden.Authorization: Bearer <token> on every subsequent request.The rejectUnlessPortalUserType Guard
Every route in the Employee Portal is wrapped with a middleware guard that calls rejectUnlessPortalUserType('employee'). This check runs after token validation and inspects the authenticated user’s user_type column.
| Scenario | Token Valid? | user_type | Result |
|---|---|---|---|
| Normal employee login | Yes | employee | 200 OK |
| Admin token used on employee route | Yes | admin | 403 Forbidden |
| Contractor token used on employee route | Yes | contractor | 403 Forbidden |
| Token expired or missing | No | N/A | 401 Unauthorized |
| Employee token used on admin route | Yes | employee | 403 Forbidden |
user_type mismatch triggers a 403 on every non-employee route. Likewise, admin or contractor tokens will always fail on Employee Portal endpoints.
No Permission Sub-System
Unlike the Admin portal (which has a granular permission matrix — e.g., can_manage_invoices, can_view_reports), the Employee Portal has no role or permission sub-system. Every authenticated employee can call every Employee Portal endpoint. Access control is binary: either you are a valid employee (pass) or you are not (fail).
✓ What Any Employee Can Do
View own dashboard KPIs, see assigned tasks, clock in/out, log site visits, upload gallery items, view calendar, read notifications.
✗ What No Employee Can Do
Access other employees’ data, update task status, change gallery visibility, create calendar events, dismiss notifications programmatically (API), or access any admin/client/contractor endpoint.
Using the Bearer Token
Authorization: Bearer 1|AbCdEfGhIjKl...
// Example authenticated request:
GET /api/employee/dashboard
Authorization: Bearer 1|AbCdEfGhIjKl...
Accept: application/json
Dashboard KPIs
EMP-003The employee dashboard (GET /api/employee/dashboard) returns a lightweight summary of the employee’s current work state. It is the entry point for the Employee Portal app and provides two numeric KPIs plus a personalized welcome message.
| KPI Field | Source Model | Query Condition | Meaning |
|---|---|---|---|
assignment_count |
StaffProfile |
user_id = auth()->id() AND business_id = employee_business_id |
Number of staff profile records linked to this employee in the current business context (typically 1, but allows for multi-business setups) |
open_task_count |
EmployeeTask |
assigned_user_id = auth()->id() AND status = 'pending' |
Number of tasks currently assigned to the employee that have not yet started or been completed |
welcome_message |
Generated | N/A | Personalized greeting string, e.g. “Welcome back, Ali!” |
Underlying Queries
SELECT COUNT(*) FROM staff_profiles
WHERE user_id = {auth_id}
AND business_id = {employee_business_id};
-- open_task_count
SELECT COUNT(*) FROM employee_tasks
WHERE assigned_user_id = {auth_id}
AND status = 'pending';
-- The controller composes the response:
return [
'assignment_count' => $assignmentCount,
'open_task_count' => $openTaskCount,
'welcome_message' => 'Welcome back, ' . $user->name . '!'
];
open_task_count only counts tasks with status='pending'. Tasks that are in_progress are not included in this count. If a task has been picked up (moved to in_progress) but not yet completed, it will disappear from the dashboard counter. This is intentional — the counter is a “new work” alert, not a total workload indicator.
Task Lifecycle
EMP-004Employee tasks are work items created and managed by Admin users and assigned to individual employees via the assigned_user_id field. The Employee Portal provides read-only access to tasks — employees can view their task list but cannot create, update, or delete tasks. All lifecycle transitions are performed by Admin or Staff users.
Task Status State Machine
All status transitions are Admin-initiated. Employees observe status changes but cannot trigger them.
Task Priority Values
EmployeeTask Fields
| Field | Type | Description | Visible to Employee? |
|---|---|---|---|
id | integer | Auto-increment primary key | Yes |
title | string | Short task description | Yes |
description | text (nullable) | Detailed instructions or context | Yes |
status | enum | pending / in_progress / completed / cancelled | Yes (read-only) |
priority | enum | high / normal / low | Yes |
assigned_user_id | integer (FK) | Links to Auth.id — the employee this task belongs to | Implicit (scoping field) |
project_id | integer (nullable FK) | Optional project association | Yes |
project_name | string (derived) | Denormalized from project.name via eager-loaded relation; empty string if project_id is null | No — read-only, API-computed |
due_date | date (nullable) | Target completion date | Yes |
business_id | integer (FK) | Owning business | Implicit |
Task List Query
SELECT * FROM employee_tasks
WHERE assigned_user_id = {auth_id}
ORDER BY id DESC;
-- Returns newest tasks first (most recently created at top)
5-Step Task Flow (Employee Perspective)
title, description, priority, due_date, project_id, and sets assigned_user_id to the employee’s user ID. Task is created with status='pending'.AppNotification for the employee: “New task assigned: [title]”. The task appears in the employee’s task list immediately.GET /api/employee/tasks and sees the new task at the top of the list (ordered by id DESC). Dashboard open_task_count has incremented by 1.status to in_progress or completed via Admin Portal after verifying the work. The employee cannot trigger this transition.open_task_count on the dashboard.PATCH /api/employee/tasks/{id} endpoint. All task lifecycle transitions must be performed by an Admin or Staff user in their respective portals.
Time Clock System
EMP-005The time clock system allows employees to record their working hours directly from the mobile or web app. The API enforces a strict one-open-entry rule: an employee can only have one active clock-in record at a time. Attempting to clock in while already clocked in, or to clock out without an open entry, returns a 422 Unprocessable Entity error with a descriptive message.
TimeClockEntry Fields
| Field | Type | Set By | Description |
|---|---|---|---|
id | integer | Auto | Primary key |
user_id | integer (FK) | API (auth) | The authenticated employee — scoping field |
business_id | integer (FK) | API | Inherited from employee’s StaffProfile |
project_id | integer (nullable FK) | Employee (optional) | Project this time entry is billed/attributed to |
project_name | string (derived) | API | Denormalized from project.name; empty string if project_id is null |
notes | text (nullable) | Employee (optional) | Free-text notes (can be updated on clock-out) |
clock_in_at | datetime | API (now()) | Auto-set to current server time on clock-in |
clock_out_at | datetime (nullable) | API (now()) | NULL while entry is open; set on clock-out |
Clock-In Endpoint
// Accepted body fields:
{ project_id: 4, // nullable int — optional
notes: "Starting foundation inspection" // nullable — optional
}
// Server-side logic:
$open = TimeClockEntry::where('user_id', auth()->id())
->whereNull('clock_out_at')->first();
if ($open) abort(422, "Already clocked in");
TimeClockEntry::create([
'clock_in_at' => now(),
'user_id' => auth()->id(),
'business_id' => $employee->business_id,
'project_id' => $request->project_id,
'notes' => $request->notes,
]);
Clock-Out Endpoint
// Accepted body fields:
{ notes: "Completed north wall inspection" // nullable — updates notes if provided
}
// Server-side logic:
$open = TimeClockEntry::where('user_id', auth()->id())
->whereNull('clock_out_at')->first();
if (!$open) abort(422, "Not clocked in");
$open->update([
'clock_out_at' => now(),
'notes' => $request->notes ?? $open->notes,
]);
Duration & Pay Calculation Formulas
The API does not compute duration or pay directly — it stores raw clock_in_at and clock_out_at datetimes. Duration and pay are computed client-side or by a reporting layer.
duration_hours = (clock_out_at - clock_in_at).total_seconds() / 3600
// Weekly total for an employee:
weekly_total = SUM(duration_hours)
WHERE user_id = N
AND clock_in_at BETWEEN week_start AND week_end
// Pay calculation (if hourly rate is stored):
pay_amount = weekly_total * hourly_rate
// Example: 9.0 hours @ $35/hr = $315.00
Clock History Query
SELECT * FROM time_clock_entries
WHERE user_id = {auth_id}
ORDER BY clock_in_at DESC;
5-Step Clock Flow
POST /api/employee/clock/in with optional { project_id: 4, notes: "Starting morning shift" }.clock_out_at IS NULL). If none exists, creates a new TimeClockEntry with clock_in_at = now(). Returns the new entry.clock_out_at IS NULL), sets clock_out_at = now(), updates notes if provided. The entry is now closed and appears in history with a computed duration.project_id means the time entry is unattributed and will need manual assignment by Admin if required for billing.
Site Visits
EMP-006A Site Visit is a structured log entry recording that an employee attended a physical location for a specific purpose — typically a field inspection, progress check, or client meeting. It is distinct from a task (which is a work item assigned by Admin) and from a time clock entry (which tracks time worked). A site visit focuses on what was observed rather than how long was worked.
status='completed' automatically. The employee cannot change this. The completed status signals that the visit has been performed and the record is a historical log, not an upcoming event.
Site Visit Fields
| Field | Validation | Set By | Description |
|---|---|---|---|
id | Auto | Database | Primary key |
title | required, string, max:255 | Employee | Short description of the visit purpose |
notes | nullable | Employee | Detailed observations, findings, or follow-up items |
project_id | nullable int | Employee | Optional project association |
project_name | string (derived) | API | Denormalized from project.name; empty string if project_id is null |
visited_at | nullable date | Employee (or defaults to now()) | When the visit occurred; supports backdating |
status | Auto-set | API always sets 'completed' | Always completed — cannot be changed |
user_id | Auto | API (auth) | The employee who logged the visit |
business_id | Auto | API | Business context |
Create Site Visit Request
{ title: "Foundation inspection — north wall", // required
notes: "Hairline crack found at column B3, ~15cm long, no displacement",
project_id: 4,
visited_at: "2024-03-15" // nullable — defaults to today
}
// API auto-sets:
status = "completed",
user_id = auth()->id(),
business_id = $employee->business_id
Backdating Support
The visited_at field accepts any valid date string, including past dates. This supports end-of-day logging workflows where employees fill in their visit log at the end of a shift rather than immediately upon returning from the site.
visited_at is omitted from the request, the system will store null (or the current date, depending on the model default). Always send visited_at explicitly for accurate site visit timestamps, especially when backdating.
SiteVisit vs. DailyLog (Contractor) Comparison
Both models capture field activity, but they serve different actor roles and purposes:
| Model | Actor | Primary Purpose | Key Fields | Status Lifecycle |
|---|---|---|---|---|
SiteVisit |
Employee | Observation report & findings log | title, notes, visited_at, project_id | Always completed on creation |
DailyLog |
Contractor | Labour headcount + daily work summary | headcount, work_summary, weather, equipment | draft → submitted → approved |
4-Step Site Visit Logging Flow
POST /api/employee/site-visits with the form data. API validates title is present, auto-sets status=completed, and saves the record.GET /api/employee/site-visits), ordered by visited_at DESC. Admin can view all employee site visits in the Admin Portal.Gallery Upload
EMP-007The gallery feature allows employees to upload site photos, inspection images, and progress documentation directly through the app. Uploaded images are stored as GalleryItem records. The employee can view their own uploads but has no control over visibility — that is an Admin-only privilege.
Scoping Rule
SELECT * FROM gallery_items
WHERE uploaded_by = {auth_id}
ORDER BY id DESC;
-- Note: uses uploaded_by, not user_id
Visibility Model
Gallery items have a visibility attribute controlled exclusively by Admin. The employee’s upload creates the item but does not set visibility. The workflow is:
| Visibility Level | Who Can See | Set By |
|---|---|---|
internal | Admin and Staff users only | Admin |
client | Admin, Staff, and the associated Client | Admin |
public | Anyone with access to the public gallery | Admin |
3-Step Gallery Upload Flow
POST /api/employee/gallery multipart request with the image file and metadata (title, project_id, etc.). API saves the file and creates a GalleryItem record with uploaded_by = auth()->id(). Visibility is not yet set.visibility to internal, client, or public based on content sensitivity. The image is now available to the appropriate audience.GET /api/employee/gallery) returns all items WHERE uploaded_by = auth()->id() regardless of visibility setting. The employee is the author and can always review what they’ve uploaded.
Calendar
EMP-008The calendar feature surfaces events that an Admin has created and assigned to the employee. The employee portal provides read-only access to calendar events — employees can view scheduled meetings, site visits (calendared), and project milestones but cannot create or modify events.
Calendar Query
SELECT * FROM calendar_events
WHERE assigned_user_id = {auth_id}
ORDER BY starts_at ASC;
-- Note: ordered ASC (upcoming first), unlike tasks which are DESC
CalendarEvent Fields
| Field | Type | Description |
|---|---|---|
id | integer | Primary key |
title | string | Event name (e.g., “Site Meeting — Block A”) |
starts_at | datetime | Event start time — used for ordering (ASC) |
ends_at | datetime (nullable) | Event end time |
project_id | integer (nullable FK) | Associated project |
project_name | string (derived) | Denormalized from project.name; empty string if project_id is null |
event_type | string (nullable) | Category: meeting, milestone, inspection, etc. |
assigned_user_id | integer (FK) | The employee this event is assigned to — scoping field |
business_id | integer (FK) | Business context |
Shared Model — Cross-Portal Awareness
The CalendarEvent model is shared across all portals that use calendar functionality. The same table serves Admin, Employee, and potentially other portals. Data isolation is achieved by the assigned_user_id scope. This means:
Admin Portal View
Admin sees ALL calendar events across the entire business, filtered by business_id. Can create, update, and delete events. Assigns events to employees via assigned_user_id.
Employee Portal View
Employee sees ONLY events where assigned_user_id = auth()->id(). Read-only. Cannot create or modify events. Sees upcoming events first (ASC ordering).
ASC by starts_at (soonest first), while Tasks and Clock entries are ordered DESC by id (newest first). This matches the use case: employees want to see what’s coming up next on the calendar, but want the most recent tasks and clock entries at the top of those lists.
Notifications
EMP-009The Employee Portal exposes two notification endpoints: a bell dropdown (recent unread) and a bulk mark-all-read action. Notifications are generated by system events (task assignment, calendar creation, site-visit review) and Admin-initiated announcements. Employees cannot delete or create notifications.
Endpoints
| Method | Path | Purpose | Auth guard |
|---|---|---|---|
GET | /api/employee/notifications | Bell dropdown — ≤20 most-recent unread notifications | user_type === 'employee' |
PATCH | /api/employee/notifications/read-all | Mark every unread notification as read for the authenticated employee | user_type === 'employee' |
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; capped at 20 rows; no pagination key
Mark-All-Read Action
UPDATE t_app_notifications
SET is_read = true
WHERE user_id = {auth_id}
AND is_read = false;
-- Returns: { "success": true, "msg": "All marked read", "data": null }
Response Row Shape (GET)
| Field | Type | Description |
|---|---|---|
id | integer | Notification PK — also determines sort order (DESC) |
type | string | Machine-readable event type, e.g. task_assigned, calendar_event |
title | string | Short notification headline for display |
body | string | Full notification message (empty string if null) |
is_read | boolean | Always false in the bell dropdown response (only unread returned) |
data | object | Arbitrary JSON payload — e.g. { "task_id": 45 }; empty object if null |
created_at | string (ISO 8601) | When the notification was created |
Common Notification Triggers
| Trigger Event | type value | Example Title |
|---|---|---|
| Admin assigns a new task to employee | task_assigned | "New task assigned: Inspect foundation north wall" |
| Admin creates a calendar event for employee | calendar_event | "New event: Site Meeting — Block A" |
| Admin updates employee's task status | task_updated | "Task updated: Inspect foundation is now in_progress" |
| Admin sends manual announcement | announcement | "Important: Safety briefing at 9 AM tomorrow" |
| Admin reviews a site visit or gallery upload | site_visit_reviewed | "Your site visit log has been reviewed" |
WHERE user_id = Auth::id() is the only isolation mechanism — there is no business_id column on t_app_notifications. The user account itself is scoped to one business, so cross-business leakage is impossible.
End-to-End Employee Journey
EMP-010This scenario traces a complete working day for Ali Hassan, a Site Engineer at a construction company using the Engineering Services platform. It illustrates how every Employee Portal feature connects in real-world usage and shows the interplay between Admin actions and employee-facing data.
StaffProfile for Ali Hassan in the Admin Portal: name="Ali Hassan", role="Site Engineer", business_id=3. Admin then creates an Auth user record with email="ali@blockaconstruction.com", user_type="employee", and links it: staff_profiles.user_id = 17. Ali receives his credentials by email.user_type="employee". API validates, returns Sanctum token. Ali sees his dashboard: assignment_count=2 (two staff profile records in the business), open_task_count=3 (three pending tasks), welcome_message="Welcome back, Ali Hassan!".POST /api/employee/clock/in with { project_id: 4, notes: "Morning shift — foundation inspection" }. API checks no open entry exists, creates TimeClockEntry with clock_in_at="08:00:00". Status bar in app shows “Clocked In — 08:00 AM”.GET /api/employee/tasks. Three tasks are returned, all status="pending":
- Task #42: “Inspect foundation north wall” — priority: high, due: today
- Task #39: “Document rebar placement” — priority: normal
- Task #35: “Submit weekly safety checklist” — priority: normal
- Title: “Foundation inspection — hairline crack on north wall”
- Notes: “Crack at column B3, ~15cm, no displacement. Recommend structural engineer review.”
- Project: Residential Block A (project_id=4)
- Visited at: today’s date
POST /api/employee/site-visits. API auto-sets status="completed". Visit is saved as record #31.
GalleryItem record with uploaded_by=17. Visibility is not yet set — Admin will review and classify these as internal or client-visible.pending to in_progress. The system generates a notification for Ali: “Task updated: Inspect foundation north wall is now in_progress”. Ali’s open_task_count drops from 3 to 2 on his next dashboard refresh.CalendarEvent: title="Site Meeting — Block A Structural Review", starts_at="2024-03-16 14:00:00", ends_at="2024-03-16 15:30:00", assigned_user_id=17, event_type="meeting". A notification is sent to Ali: “New event: Site Meeting — Block A Structural Review”.POST /api/employee/clock/out with { notes: "Foundation inspection complete, site visit logged, 3 photos uploaded" }. API finds the open entry, sets clock_out_at="17:00:00".
clock_out = 17:00:00
duration = 9.0 hours
// (17:00 - 08:00) = 9.0 × 3600 seconds / 3600 = 9.0 hours
GET /api/employee/calendar. Returns the new event: “Site Meeting — Block A Structural Review” on tomorrow at 2:00 PM. Ali adds it to his personal calendar. He’s prepared: the site visit notes and photos are on record for the structural engineer at the meeting.Day Summary
| Activity | API Endpoint | Record Created/Updated |
|---|---|---|
| Login | POST /api/login | Sanctum token issued |
| Dashboard check | GET /api/employee/dashboard | Read-only KPIs |
| Clock in | POST /api/employee/clock/in | TimeClockEntry #88 created (open) |
| Task list | GET /api/employee/tasks | Read-only — 3 tasks returned |
| Site visit | POST /api/employee/site-visits | SiteVisit #31 created |
| Gallery upload (x3) | POST /api/employee/gallery | GalleryItem #55, #56, #57 created |
| Clock out | POST /api/employee/clock/out | TimeClockEntry #88 closed, 9h logged |
| Calendar check | GET /api/employee/calendar | Read-only — 1 event returned |
| Notifications | GET /api/employee/notifications | Read-only — 2 notifications |
Error Reference
EMP-011The following table documents all error responses that an Employee Portal client may encounter. Errors are returned as JSON with an appropriate HTTP status code and a descriptive message field.
| HTTP Code | Message / Trigger | Root Cause | Resolution |
|---|---|---|---|
| 401 | Unauthenticated | Authorization header is missing, the token has expired, or the token has been revoked (e.g., user logged out from another device). | Re-authenticate via POST /api/login. Store the new token and retry the request. |
| 403 | Forbidden — user_type mismatch | The authenticated user’s user_type is not employee. Common causes: using an admin or contractor token on an employee endpoint, or the user account was changed to a different type after the token was issued. |
Ensure the login was performed with user_type="employee" credentials. Do not reuse tokens across portal types. If the account was reclassified, contact the system Admin. |
| 422 | “Already clocked in” | Employee sent POST /api/employee/clock/in while a TimeClockEntry with clock_out_at IS NULL already exists for their user_id. Only one open entry is allowed at a time. |
Clock out first via POST /api/employee/clock/out, then clock in again if a new session is needed. Check clock history (GET /api/employee/clock) to verify the open entry. |
| 422 | “Not clocked in” | Employee sent POST /api/employee/clock/out but no open TimeClockEntry (clock_out_at IS NULL) exists for their user_id. Cannot clock out without first clocking in. |
Clock in first via POST /api/employee/clock/in. If the employee believes they were clocked in but the entry is missing, contact Admin — a manual correction may be needed in the Admin Portal. |
| 422 | Validation error — “title is required” | A required field was omitted from the request body. The most common case is POST /api/employee/site-visits without a title field. The errors object in the response will list all failing fields. |
Include all required fields. For site visits: title is mandatory. For clock-in: no required fields (project_id and notes are both nullable). Check the full errors response object for a field-by-field breakdown. |
| 404 | Not Found | The requested resource does not exist, or it exists but belongs to a different user (isolation prevents returning 403 on resource-not-found to avoid leaking existence information). | Verify the resource ID is correct and belongs to the authenticated employee. If the ID was provided by Admin, confirm it was set up correctly. |
| 500 | Internal Server Error | Unexpected server-side error. Not caused by client input. May indicate a database connection issue, missing configuration, or an application bug. | Retry the request after a short delay. If the error persists, report it to the system administrator with the request details and timestamp. Check server logs for the exception and trace. |
Error Response Format
{
"message": "The title field is required.",
"errors": {
"title": [ "The title field is required." ]
}
}
// Business logic error (422 — clock state):
{
"message": "Already clocked in"
}
// Auth guard error (403):
{
"message": "Forbidden"
}
Clock State Machine — Error Conditions
The time clock system is a two-state machine. The following diagram shows when errors occur: