Skip to content

EES Survey Portal — Backend Engineering Documentation

Audience: Backend developers joining or maintaining the "EES Survey Portal" backend (the Laravel application that serves the employee-facing, survey-taking web app). Goal: Enable a new backend engineer to understand the architecture, business logic, data flow, and codebase well enough to be productive without a live knowledge-transfer session.

Method: Everything below is inferred from the actual source in this repository (d:/Downloads/EES Documentation/EES-2026/Ees-survey-backend). Where a fact cannot be derived from the code, it is explicitly marked "Unable to determine from the codebase." Assumptions are labelled (Assumption). Facts are stated plainly, with path/to/file.php:LINE citations.

Scope note. This is the backend that the separately-documented React frontend at EES-2026/EES-Survey-Portal calls under /survey-api/* (see docs/survey-portal-frontend-documentation.md). This backend is not the "EES Report backend" (EES-2026/EES - Report backend, documented separately at docs/ees-backend-documentation.md) — that is a different Laravel application serving a different product (the admin/analytics report portal). The two share no code in this checkout; they are cross-referenced only where the product relationship is evidenced by the code itself.


Table of Contents

  1. Backend Overview
  2. Project Structure
  3. Architecture
  4. Module / Controller Breakdown
  5. Request Lifecycle
  6. Business Logic
  7. API Layer
  8. Database Interaction
  9. Authentication & Authorization
  10. Background Processing
  11. Integrations
  12. Configuration
  13. Logging & Error Handling
  14. Performance Considerations
  15. Security
  16. Developer Guide
  17. Code Quality Review
  18. Improvement Opportunities
  19. System Diagrams
  20. Appendix — Assumptions & "Unable to determine" items

1. Backend Overview

Purpose

This repository ("Ees-survey-backend") is the Laravel 11 backend for the EES Survey Portal — the employee-facing, single-purpose survey-taking web application in the EES (Employee Engagement Survey) product line, operated by a consultancy (email domain engageconsulting.biz appears throughout, e.g. app/Http/Controllers/AdminController.php:12,23-26,313). It has two responsibilities:

  1. Serve the public, unauthenticated /survey-api/* JSON endpoints that the React SPA at EES-2026/EES-Survey-Portal calls to resolve a questionnaire from an invite-link token, fetch questions, submit answers, select a language, and submit the completed survey (routes/web.php:28-37, app/Http/Controllers/SurveyApiController.php).
  2. Serve a small, session-authenticated internal admin portal (Blade views under resources/views/admin/*) used by consultancy staff to seed/manage survey participants, view a completion summary, and send bulk email/WhatsApp invites (routes/web.php:15-26, app/Http/Controllers/AdminController.php).

It also serves the pre-built React SPA itself as static assets out of public/ (a committed Vite build, not built by this repository — see §2) and a matching Blade wrapper (resources/views/frontend/survey-main.blade.php) via GET / (routes/web.php:13, app/Http/Controllers/SurveyController.php:7-9).

Main Responsibilities

Responsibility Owning code
Resolve a questionnaire by invite-link token, fetch questions/scales, and serve them to the frontend app/Http/Controllers/SurveyApiController.php::getQuestionnaire
Persist individual answers (Likert-scale and NPS/open-text types) as they are submitted SurveyApiController::submitAnswer
Mark a questionnaire complete, once all questions have a saved answer SurveyApiController::submitQuestionnaire
Reset/suspend/delete a questionnaire's state (ops tooling) SurveyApiController::softResetQuestionnaire, hardResetQuestionnaire, suspendQuestionnaire, deleteQuestionnaire
Persist the employee's chosen UI language SurveyApiController::selectLanguage
Serve static branding metadata (currently unimplemented stub) SurveyApiController::getStyleMetadata
Look up a questionnaire's invite URL by employee id/email SurveyApiController::getUrlByIdentifier
Generate participant records + their per-question answer rows (survey seeding) AdminController::standardSurveyCreator, seedFunction
Staff-facing participant CRUD and completion-status summary (session-auth Blade UI) AdminController::participantListView, surveySummaryView, addParticipant*, UpdateParticipant*
Bulk email invites via SendGrid AdminController::sendEmailRequest, app/Services/SendGridMailService.php
WhatsApp template invites via Twilio AdminController::sendWhatsappRequest, app/Services/WhatsAppTemplateService.php, app/Jobs/SendWhatsAppMessageJob.php
Serve the pre-built React SPA + its static assets public/index.html, public/assets/*, resources/views/frontend/survey-main.blade.php, SurveyController::surveyMainView
Session/Blade login for the admin portal Auth::routes(...) (routes/web.php:8) + stock Laravel UI app/Http/Controllers/Auth/*

High-Level Architecture

flowchart TB
    subgraph Clients
        SPA["React SPA - EES-Survey-Portal - served from public - assets and or fetched separately"]
        Staff["Consultancy staff browser - session-authenticated"]
    end

    subgraph Laravel["Laravel 11 App - this repo"]
        WebRoutes["routes/web.php - single route file"]
        SurveyApi["SurveyApiController - /survey-api/star - NO auth middleware"]
        Admin["AdminController - /admin/star - auth session middleware"]
        SurveyCtrl["SurveyController - GET / - serves SPA shell"]
        AuthCtrls["Auth/* - Laravel UI login only"]
        Services["Services - SendGridMailService, WhatsAppTemplateService"]
        Jobs["Jobs - SendWhatsAppMessageJob - queued"]
        Models["Eloquent Models - Entity, Questionnaire, Scale, ScaleOptions, User"]
        Blade["Blade Views - resources/views/admin, auth, frontend"]
    end

    DB[("MySQL/other RDBMS - externally-managed schema - no migrations in this repo")]
    SendGrid[["SendGrid Email API"]]
    Twilio[["Twilio WhatsApp API"]]
    QueueBackend[("Queue backend - DB table 'jobs' by default")]

    SPA -->|"POST JSON, no auth header required"| SurveyApi
    Staff -->|"session cookie"| Admin
    Staff -->|"GET/POST login"| AuthCtrls

    SurveyApi --> Models
    SurveyApi -->|"raw DB::select/update/delete"| DB
    Admin --> Models
    Admin -->|"raw DB::select"| DB
    Admin --> Services
    Services -->|"HTTPS"| SendGrid
    Services -->|"HTTPS"| Twilio
    Admin -.->|"dispatch - not currently used by controller"| Jobs
    Jobs -->|"handle"| Services
    Jobs -.-> QueueBackend

    WebRoutes --> SurveyApi
    WebRoutes --> Admin
    WebRoutes --> SurveyCtrl
    WebRoutes --> AuthCtrls
    Admin --> Blade
    SurveyCtrl --> Blade
    Models --> DB

Tech Stack

Layer Technology Version (from composer.json / composer.lock)
Language PHP ^8.2 (composer.json:9)
Framework Laravel ^11.31, pinned v11.34.2 in composer.lock
Session/Blade auth scaffolding Laravel UI ^4.6 (composer.json:12)
Outbound email sendgrid/sendgrid ^8.1, pinned 8.1.2
WhatsApp messaging twilio/sdk ^8.8, pinned 8.8.4
REPL/debugging laravel/tinker ^2.9
Dev/test phpunit/phpunit ^11.0.1, laravel/pail, laravel/pint, laravel/sail, mockery, fakerphp/faker dev-only

Source: composer.json:8-24, composer.lock.

Key Libraries in Use (by purpose)

Category Library Used for
Session auth laravel/ui + stock Laravel Auth facade The /admin/* Blade-portal login (Auth::routes(['reset' => false, 'register' => false, 'verify'=>false]), routes/web.php:8)
Email sendgrid/sendgrid App\Services\SendGridMailService::sendBulkEmail — bulk personalized invite email
WhatsApp twilio/sdk App\Services\WhatsAppTemplateService::sendTemplateMessage — template-based WhatsApp invite messages
Background jobs Laravel's built-in queue (ShouldQueue) App\Jobs\SendWhatsAppMessageJob — queued WhatsApp send (present but not currently dispatched by any live code path, see §10)

Note on app.zip: a 47 MB app.zip file exists at the repository root of this Laravel app. This is almost certainly a build/deployment artifact accidentally committed to source control (a zipped copy of a prior deployment payload). Its contents were not analyzed as part of this documentation pass; it should be removed from version control and, if needed, replaced with a proper CI/CD deployment pipeline.


2. Project Structure

This is a near-stock Laravel 11 application skeleton — there is no custom modular structure; all logic lives in the conventional app/ tree, concentrated in two controllers.

Ees-survey-backend/
├── app/
│   ├── Http/
│   │   ├── Controllers/
│   │   │   ├── Auth/                    # Laravel UI scaffolding (Login/Register/ForgotPassword/Reset/Confirm/Verification)
│   │   │   ├── AdminController.php      # 342 lines — participant mgmt, survey seeding, email/WhatsApp invites, summary
│   │   │   ├── Controller.php           # base controller (stock)
│   │   │   ├── SurveyApiController.php  # 444 lines — the core of this app: public survey-taking API
│   │   │   └── SurveyController.php     # 10 lines — serves the SPA shell
│   │   └── Middleware/
│   │       └── RedirectIfAuthenticated.php   # aliased as 'guest'; redirects logged-in users to /admin
│   ├── Jobs/
│   │   └── SendWhatsAppMessageJob.php   # queued job wrapping WhatsAppTemplateService
│   ├── Models/                          # 5 Eloquent models — see §8
│   │   ├── Entity.php                   # empty model, no $fillable/relationships declared
│   │   ├── Questionnaire.php            # $table='questionnaires', $fillable declared
│   │   ├── Scale.php                    # hasMany ScaleOptions
│   │   ├── ScaleOptions.php             # belongsTo Scale
│   │   └── User.php                     # stock Laravel Authenticatable
│   ├── Providers/
│   │   └── AppServiceProvider.php       # empty register()/boot()
│   └── Services/
│       ├── SendGridMailService.php      # static sendBulkEmail() helper
│       └── WhatsAppTemplateService.php  # Twilio wrapper, sync + queued-dispatch variants
├── bootstrap/
│   └── app.php                          # route registration + CSRF exemption for /survey-api/* + 'guest' alias
├── config/                              # stock Laravel config, plus app.php has custom client_* keys, services.php has sendgrid key
├── database/
│   └── .gitignore                       # NO migrations/, NO seeders/, NO factories/ present — see §8
├── public/
│   ├── index.php                        # Laravel front controller
│   ├── index.html                       # a PRE-BUILT React SPA shell (Dubai Islamic Bank build) — NOT built by this repo
│   ├── assets/                          # pre-built Vite JS/CSS/font/image bundle — the DIB build's compiled output
│   ├── survey-main.blade.php            # a DIFFERENT client's (Martin Dow) pre-built SPA shell, committed at the public root
│   ├── branding.json                    # runtime-fetched bilingual welcome copy — the same file the frontend reads
│   ├── smileyLikert/, smileys/          # Likert-scale option images, shared with the frontend's asset set
│   ├── css/, js/, img/                  # Bootstrap/jQuery + the ADMIN portal's static assets (not SPA-related)
│   └── .htaccess
├── resources/
│   └── views/
│       ├── admin/                       # add-participant, participant-list, send-email, survey-summary, update-participant
│       ├── auth/                        # login, register, passwords/*, verify (stock Laravel UI scaffolding)
│       ├── frontend/
│       │   └── survey-main.blade.php    # the DIB SPA shell, THIS is what SurveyController actually serves at GET /
│       └── layouts/
│           └── admin.blade.php          # shared admin-portal chrome (Bootstrap navbar)
├── routes/
│   └── web.php                          # 40 lines — the ENTIRE route table (no routes/api.php exists)
├── app.zip                              # 47MB — accidental build artifact, see §1
└── tests/
    ├── Feature/ExampleTest.php          # stock placeholder, tests GET / returns 200
    └── Unit/ExampleTest.php             # stock placeholder, trivial assertTrue

Folder-by-folder notes

  • app/Http/Controllers/SurveyApiController.php — the entire public survey-taking API surface. Every method follows the same shape: validate $request, run one or more raw SQL statements via DB::select/DB::update/DB::delete, and return a response()->json([...]) envelope with message and res keys. See §4.1 and §6.
  • app/Http/Controllers/AdminController.php — participant management, survey seeding, and outbound invite sending (email/WhatsApp). Mixes Eloquent (Questionnaire::create, Questionnaire::where) with raw DB::select/DB::table()->insert() calls. See §4.2.
  • app/Http/Controllers/SurveyController.php — a 10-line controller with a single method, surveyMainView(), that returns view('frontend.survey-main') (app/Http/Controllers/SurveyController.php:7-9). This Blade view is itself just a static HTML shell pointing at the pre-built SPA's hashed JS/CSS bundle in public/assets/ — it does not render any dynamic PHP content beyond the view() call.
  • database/ — contains only a .gitignore file. There is no migrations/, seeders/, or factories/ directory at all. This backend, like its sibling "EES - Report backend," runs against an externally-managed/legacy database schema that is not created or versioned by this codebase. The entire schema referenced in this document is reverse-engineered from the 5 Eloquent models plus raw SQL column references across SurveyApiController.php and AdminController.php — see §8 for the confirmed-vs-inferred breakdown.
  • public/ — this folder contains two different clients' pre-built React SPA outputs simultaneously: the currently-wired Dubai Islamic Bank ("DIB") build (public/index.html + public/assets/index-Dna7ZuZQ.js/index-BqgNuRDU.css, matching resources/views/frontend/survey-main.blade.php:7-9 exactly) and a leftover Martin Dow build shell (public/survey-main.blade.php:7-9, referencing index-C7EXaJYr.js/index-DLBgWkL9.css — those hashed files are not present in public/assets/ in this checkout, confirming it is a stale leftover from a previous client engagement, not part of the current deployable). This directly explains a finding flagged as unresolved in the frontend documentation (docs/survey-portal-frontend-documentation.md §2, §15) — see §20.
  • public/branding.json — the exact static JSON file the frontend's Start.jsx fetches at runtime (d:/Downloads/EES Documentation/EES-2026/EES-Survey-Portal/public/branding.json, per the frontend doc). It lives here, in this Laravel app's public/ folder, and is served as a static file by the web server alongside the SPA's own compiled assets — meaning in production the frontend and this backend's public/ directory are deployed to (or proxied through) the same document root, not two independently hosted origins for static content. See §20.
  • resources/views/admin/*.blade.php — plain server-rendered forms/tables (Bootstrap 4 + jQuery, no SPA framework) for the internal staff portal.
  • tests/ — both Feature/ExampleTest.php and Unit/ExampleTest.php are the stock Laravel placeholders, unmodified (tests/Feature/ExampleTest.php:8-18 just asserts GET / returns 200; tests/Unit/ExampleTest.php asserts true === true). There are no real tests for any business logic in this codebase — no test coverage exists for questionnaire fetch/answer/submit, admin participant CRUD, or the email/WhatsApp integrations.

3. Architecture

Architectural pattern actually implemented

flowchart LR
    subgraph "Per Request"
        R["Route - closure-mapped in web.php"] --> C["Controller method - validates then queries directly"]
        C -->|"raw DB::select/update/delete"| DB[("MySQL/other RDBMS")]
        C -->|"Eloquent, for Questionnaire/Entity/Scale reads"| M["Models"]
        C -->|"static call"| SV["Services - SendGridMailService, WhatsAppTemplateService"]
        C --> RESP["response json or Blade view"]
    end
  1. Fat-controller, no service layer for business logic. There is no repository layer, no dedicated query-builder/DTO classes, and no FormRequest classes at all (every validation call is an inline Validator::make($request->all(), [...]) inside the controller method itself, e.g. app/Http/Controllers/SurveyApiController.php:35-38,64-66,235-238). All business logic — questionnaire resolution, answer persistence, reset/suspend/delete semantics, participant creation — is written directly inside controller methods.
  2. "Service" classes exist, but are narrowly scoped to external integrations, not general business orchestration: App\Services\SendGridMailService (SendGrid wrapper, sendBulkEmail is a static method) and App\Services\WhatsAppTemplateService (Twilio wrapper, instantiated via constructor injection in AdminController::sendWhatsappRequest(WhatsAppTemplateService $whatsapp), app/Http/Controllers/AdminController.php:294).
  3. Heavy, near-exclusive use of raw SQL via DB::select/DB::update/DB::delete/DB::table(...)->insert() for anything beyond a simple lookup-by-id, even though 5 Eloquent models exist. Questionnaire is the only model used for both reads and writes via Eloquent (Questionnaire::create, Questionnaire::where(...)->first(), Questionnaire::where(...)->update(...)); every other data access — likerts, netpromoterscores, integrity_feedbacks, entities (in AdminController::addParticipantView), and all of SurveyApiController's question/answer reads and writes — goes through raw SQL strings, several of them built with direct string interpolation of request/model data (see §15).
  4. No Repository pattern, no CQRS, no event-driven design, no custom middleware pipeline beyond one alias. app/Http/Middleware/ contains a single custom class, RedirectIfAuthenticated, aliased as guest (bootstrap/app.php:19). There are no Laravel Events/Listeners, no Jobs beyond the one WhatsApp job, and no Observers.
  5. Response conventions are inconsistent between the two controllers, but self-consistent within SurveyApiController. Every SurveyApiController method returns the same two-key JSON envelope shape — {"message": "...", "res": {...}} (success, HTTP 200) or {"message": "...", "errors": [...], "res": []} (validation failure, HTTP 400) — but this is a repeated convention, not an extracted helper/trait; each method builds the array inline. AdminController, by contrast, mixes redirect()->back()->with(...) (flash-message pattern for the Blade forms), view(...) (page renders), and one raw response()->json([...]) (in sendWhatsappRequest) — there is no single response contract across the app.
  6. No API versioning, no OpenAPI/Swagger documentation, no rate limiting configured anywhere in this codebase (no throttle: middleware is applied to any route in routes/web.php).

Layering (as it actually exists)

flowchart TB
    Route --> Controller
    Controller -->|"raw SQL, inline strings"| DB[("MySQL/other RDBMS")]
    Controller -->|"Eloquent, Questionnaire/Entity/Scale only"| DB
    Controller -->|"constructor DI or static call"| ExternalServices["SendGridMailService / WhatsAppTemplateService"]
    ExternalServices -->|"HTTPS"| ExternalAPIs["SendGrid / Twilio"]
    Controller --> Response["response json or Blade view"]

There is effectively one layer doing query construction, business logic, and response shaping: the controller method. Unlike the sibling "EES - Report backend" (which has a ProviderController acting as a shared static-method query layer), this codebase has no equivalent shared layer at all — SurveyApiController and AdminController do not call into each other, and there is no third class mediating access to questionnaires/likerts/netpromoterscores.


4. Module / Controller Breakdown

4.1 App\Http\Controllers\SurveyApiController (app/Http/Controllers/SurveyApiController.php, 444 lines)

The core of this application. All 9 methods are mapped from routes/web.php:28-37 as plain POST routes with no middleware group at all — they are not inside the Route::middleware(['auth'])->group(...) block that wraps /admin/* (routes/web.php:15-26), and there is no auth:sanctum/API-token middleware applied anywhere in this route file (there is no routes/api.php in this project — everything is registered in routes/web.php, and bootstrap/app.php:9-11 only wires web routing, no api routing group). See §9 and §15 for the full implication.

  • getStyleMetadata(Request $request) (:22-32) — returns a hardcoded stub JSON object with all-empty string values for logo_url, bg_img_desktop_url, bg_img_phone_url, prm_color_hex, prm_color_rgb, sec_color_hex, sec_color_rgb. No request parameters are read, no database query is made. This endpoint is not implemented — it always returns the same static empty payload regardless of which client/survey is asking. This is very likely intended to eventually read from config/app.php's client_prefix/client_clr_prm_hex/etc. keys (see §12), which already exist for exactly this purpose but are not wired into this method.
  • getUrlByIdentifier(Request $request) (:34-61) — validates identifier_type (emp_id or email) and identifier_value, then runs SELECT url FROM questionnaires WHERE " . $request->identifier_type . " = '" . $request->identifier_value . "'" (:46) — both the column name and the value are string-interpolated directly into the SQL, not bound as parameters. Because identifier_type is constrained by the validator to exactly emp_id or email (an enum via in:emp_id,email, :36), the column-name injection vector is closed by validation, but identifier_value is inserted into the SQL string with no escaping or parameter binding at all — a classic SQL-injection pattern, see §15.
  • getQuestionnaire(Request $request) (:63-199) — validates qstr_url exists in questionnaires.url, loads the Questionnaire row, then, only if $qstr->status == 'pending' (:87), runs three separate raw SQL queries joining likerts/netpromoterscores to questions to build the question list (Likert-type excluding the integrity question id 1, NPS-type, and the integrity question itself), attaches hardcoded bilingual follow-up-question definitions to NPS question id 36 specifically (:124-158, string-matched by literal id), and returns all Scale rows with their scale_options eager-loaded (Scale::with('scale_options')->get(), :187). If the questionnaire is not pending (i.e. completed, suspended, or closed), questions and scales are returned as empty collections — the frontend is expected to interpret this state from qstr.status (see the frontend's StatusRoute guard, which redirects on status === "completed"). This is the single largest and most business-logic-dense method in the controller — see §6.1.
  • submitAnswer(Request $request) (:234-325) — validates qstr_url and ans.type (lkrt or nps), then branches into a switch with per-type validation rules applied a second time: for lkrt, requires ans.score (1-5 integer), ans.ans_state (yes/no), ans.ans_id (must exist:likerts,id), and conditionally ans.other_text (required_if:ans.qst_id,1 — i.e. only mandatory for question id 1, the integrity question); for nps, requires ans.score (0-10 integer), ans.other_text (required but nullable — an unusual combination, see §17), ans.ans_state, and ans.ans_id (must exist:netpromoterscores,id). On success it runs a single UPDATE against the matching table (likerts or netpromoterscores) keyed by ans_id, and for lkrt answers to question id 1 specifically, additionally updateOrInserts a row in integrity_feedbacks keyed by likert_id (:271-276). No questionnaire_id scoping is applied to the UPDATE — the update targets likerts/netpromoterscores purely WHERE id = <ans_id> (:264,301), relying entirely on ans_id having been validated to exist:likerts,id/exist:netpromoterscores,id, with no check that the given ans_id actually belongs to the questionnaire identified by qstr_url — see §15 for the cross-questionnaire-answer-tampering implication.
  • submitQuestionnaire(Request $request) (:201-232) — validates qstr_url, then checks whether any likerts or netpromoterscores row for that questionnaire has a NULL score or NULL ans_state (:214-215); if so, returns a 400 "Response to all questions have not been submitted yet." Otherwise sets questionnaires.status = 'completed' (:223). This is the sole business rule gating survey completion — see §6.2.
  • softResetQuestionnaire(Request $request) (:327-347) — sets questionnaires.status = 'pending' only. Does not touch any answer data. Used to let a respondent re-enter a survey without losing already-given answers (e.g., to unwind an accidental submitQuestionnaire completion, or reopen a suspended survey).
  • hardResetQuestionnaire(Request $request) (:349-372) — sets status = 'pending' and lang = NULL, then nulls out score/ans_state on every likerts row and score/other_text/ans_state on every netpromoterscores row for that questionnaire, and deletes the associated integrity_feedbacks row. This fully rewinds a questionnaire to its pristine, never-started state while keeping the same likerts/netpromoterscores rows (and thus the same ans_ids) — it does not re-shuffle or regenerate the question set.
  • deleteQuestionnaire(Request $request) (:374-397) — hard-deletes the questionnaires row itself, plus its integrity_feedbacks, likerts, and netpromoterscores rows. This is destructive and irreversible — there is no soft-delete column (deleted_at) referenced anywhere in this codebase's raw SQL or models. Note also a latent bug: the likerts/netpromoterscores cleanup statements are dispatched via DB::update(...) (:388-389) even though their SQL text is DELETE FROM ... — Laravel's DB::update() still executes the statement correctly against MySQL (the driver does not validate the SQL verb against the calling method), so this works in practice, but it is a maintenance smell (see §17).
  • suspendQuestionnaire(Request $request) (:399-419) — sets status = 'suspended' only. This is the mechanism referenced by the suspend object hardcoded into getQuestionnaire's response (:77-85) — the frontend is expected to detect an "attention check" failure (a specific Likert answer value indicating inattentive responding) and call this endpoint, though no code in this backend actually calls suspendQuestionnaire automatically — the suspend logic (detecting the attention-check failure and deciding to call this endpoint) must live entirely client-side, if it exists at all (the frontend documentation does not describe questionsService calling this endpoint — see §20).
  • selectLanguage(Request $request) (:421-443) — validates lang is eng or urdu, sets questionnaires.lang accordingly. Matches the frontend's Language.jsx call exactly ({ qstr_url, lang }, per the frontend doc §6).

4.2 App\Http\Controllers\AdminController (app/Http/Controllers/AdminController.php, 342 lines)

All methods except seedFunction and standardEmailSender-referenced-but-not-found (see below) are registered under the session-auth middleware group (routes/web.php:15-26).

  • seedFunction() (:18-30) — a hardcoded developer/ops seeding endpoint, registered as a public GET /seed route (routes/web.php:10) with no middleware at all, not even inside the auth group. It bumps max_execution_time to 600000 seconds and calls standardSurveyCreator with 4 hardcoded internal test participants (nauman@engageconsulting.biz etc., :23-26). This is a real, callable, unauthenticated endpoint in production route configuration — see §15.
  • standardSurveyCreator($participants, $surveyType = 'standard') (:32-88) — the core participant-provisioning routine, called by both seedFunction and addParticipantRequest. For each participant: creates a Questionnaire row with a deterministic invite token sha1(Config::get('app.salt') . '___' . $o['emp_id']) (:45 — see §9 and §15 for why this is significant: app.salt is not defined anywhere in config/app.php in this codebase, see §12); normalizes email (strips non-breaking spaces and whitespace, lowercases) and name (title-cases); sets status: 'pending', lang: null. It then randomly assigns a question set: selects all questions with id > 3 (excluding ids 39 and 41) of type = 'lkrt', shuffles them, and splices in two "dummy" questions (ids 2 and 3) at two random positions (:53-59) — the two random indices are structured so the second is always at least 10 questions after the first (rand($randomIndex1 + 10, 39), :57). It inserts one likerts row per selected question (questionnaire_id, question_id only — score/ans_state default to NULL at the DB level, populated later via submitAnswer). It conditionally adds one gender-specific Likert question: question id 41 if demg1 == 'Female', id 39 if demg1 == 'Male' (:67-75) — this is the only place id IN(39,41)'s exclusion from the general pool (:54) is explained; those two ids are reserved as gender-conditional questions inserted separately. It inserts one netpromoterscores row per type = 'nps' question (unconditionally, no shuffling). Finally it inserts one more likerts row for question id 1 unconditionally (:83-85) — this is the "integrity" question referenced throughout SurveyApiController (see §6).
  • redirector() (:90-93) — GET /admin redirects to /admin/survey-summary.
  • surveySummaryView(Request $request) (:95-131) — builds a completion-status pivot (pending/completed/closed/total counts) grouped by one of demg1/demg2/demg3 (selectable via ?demg=), optionally filtered by ?et_id= (entity id), via two raw SELECT ... GROUP BY queries string-interpolating both the demg column name and the et_id filter directly into the SQL (:98-122 — see §15, though et_id and demg here come from a same-origin admin form, not arbitrary public input). Renders admin.survey-summary.
  • participantListView(Request $request) (:133-145) — lists all questionnaires joined to entities, optionally filtered by et_id. Renders admin.participant-list.
  • addParticipantView() / addParticipantRequest(Request $request) (:147-183) — a manual single-participant add form; validates email format and uniqueness of email/emp_id against existing questionnaires rows (phone-uniqueness check is present but commented out, :164-165), then delegates to standardSurveyCreator with a single-element array.
  • UpdateParticipantView($id, Request $request) / UpdateParticipantRequest($id, Request $request) (:185-221) — edits an existing Questionnaire row's emp_id/name/email/entity_id/demg1-3 (does not regenerate the url token or touch likerts/netpromoterscores, so editing a participant after their questionnaire has been seeded does not re-randomize their question set). Note: the route in routes/web.php:21-22 references lowercase-first updateParticipantView/updateParticipantRequest, while the controller methods are defined as UpdateParticipantView/UpdateParticipantRequest (:185,196 — capital U). PHP method names are case-insensitive, so this works, but it is an inconsistency worth normalizing (see §17).
  • sendEmailView() / sendEmailRequest(Request $request) (:223-292) — a bulk-invite email form. Validates email_subject/email_body are required and an optional qids param matches a comma-separated-integers regex (:234); queries all pending questionnaires with a non-null email (optionally restricted to specific qids if specids is present in the request, :242-244 — note the SQL-injection-shaped string concatenation of $request->qids directly into the IN (...) clause at :243, mitigated only by the earlier regex validation, not parameter binding — see §15); builds one SendGrid Personalization object per recipient with %name%/%url% substitution tokens, and calls SendGridMailService::sendBulkEmail(...).
  • sendWhatsappRequest(WhatsAppTemplateService $whatsapp) (:294-338) — intended to bulk-send WhatsApp invites to all pending questionnaires that have a phone number and no email, but the real query's result is immediately discarded and overwritten by a hardcoded single-element test array (:314-316: $users = collect([(object)['entity' => 'Jubilee BANCA & DSF Division', 'name' => 'Nauman Azeem', ...]])) — this means, as currently written, this endpoint always sends exactly one hardcoded test WhatsApp message and never actually processes the real pending-questionnaire query it just ran. This is a clear leftover-from-testing bug, not intended production behavior — see §17, finding #1. It also calls $whatsapp->sendTemplateMessage(...) synchronously in a loop, not the queued sendBulkTemplateMessages/SendWhatsAppMessageJob path that exists in the codebase — see §10.
  • standardEmailSender — referenced by routes/web.php:11 (GET /mail/send → AdminController::standardEmailSender) but no method with this name exists in AdminController.php as read in this pass. Calling this route would raise a Laravel BadMethodCallException at runtime. This is a broken route — see §7 and §17.

4.3 App\Http\Controllers\SurveyController (app/Http/Controllers/SurveyController.php, 10 lines)

A single method, surveyMainView() (:7-9), returning view('frontend.survey-main'). No request parameters are read, no data is passed to the view. The Blade view itself (resources/views/frontend/survey-main.blade.php) is a static HTML document (not templated with @yield/{{ }} beyond the doctype) — it is a committed snapshot of a Vite-built SPA's index.html, pointing at hashed asset filenames (index-Dna7ZuZQ.js, index-BqgNuRDU.css) that exist in public/assets/ in this checkout, confirmed matching the "Dubai Islamic Bank" client build. See §2 for the sibling public/survey-main.blade.php (a different, stale client build) and §20 for how this resolves the frontend documentation's open question about that file's origin.

4.4 App\Http\Controllers\Auth\* (Laravel UI scaffolding)

LoginController, RegisterController, ForgotPasswordController, ResetPasswordController, ConfirmPasswordController, VerificationController — stock, unmodified Laravel UI scaffolding traits, generated by laravel/ui's php artisan ui:auth command. routes/web.php:8 registers Auth::routes(['reset' => false, 'register' => false, 'verify' => false]), which disables the password-reset, registration, and email-verification route groups, leaving only GET/POST /login and POST /logout actually routed (Laravel's Auth::routes() helper conditionally skips route registration based on these flags). LoginController (app/Http/Controllers/Auth/LoginController.php:21-39) uses the AuthenticatesUsers trait, sets protected $redirectTo = '/admin' (:28), and applies guest middleware to everything except logout (:37-38) — guest is aliased in bootstrap/app.php:19 to the custom RedirectIfAuthenticated middleware (app/Http/Middleware/RedirectIfAuthenticated.php), which redirects an already-authenticated user to /admin instead of Laravel's stock default. RegisterController/ResetPasswordController/ConfirmPasswordController/VerificationController all similarly set $redirectTo = '/admin' but their routes are not registered due to the Auth::routes() flags above — these controllers are dead code from a routing perspective, left over from the laravel/ui scaffold generation. The resources/views/auth/login.blade.php view is the only one of the auth/* Blade views actually reachable via a live route.


5. Request Lifecycle

Middleware (bootstrap/app.php)

Laravel 11's slim-skeleton bootstrap style is used (no app/Http/Kernel.php exists in this version of Laravel). All middleware wiring happens in bootstrap/app.php:13-20:

flowchart TB
    REQ(["Incoming request"]) --> STACK["Laravel 11 default HTTP middleware stack - Trust proxies, Trim strings, - Convert empty strings to null, etc."]
    STACK --> WEBGROUP["web middleware group - session, cookies, CSRF - CSRF EXEMPTED for /survey-api/star - bootstrap/app.php:15-17"]
    WEBGROUP --> ROUTER{"Matched route"}
    ROUTER -->|"/admin and sub-routes"| AUTHMW["'auth' middleware - session guard, redirect to /login if guest"]
    ROUTER -->|"/survey-api/star"| NOAUTH["NO middleware beyond the web group - no auth check of any kind"]
    ROUTER -->|"/seed, /mail/send, GET /"| PUBLIC["NO middleware beyond the web group"]
    ROUTER -->|"/login, /logout"| GUESTMW["'guest' middleware alias - RedirectIfAuthenticated"]
    AUTHMW --> CTRL["Controller"]
    NOAUTH --> CTRL
    PUBLIC --> CTRL
    GUESTMW --> CTRL

Source: global middleware and CSRF exemption at bootstrap/app.php:13-20; the auth route-middleware group at routes/web.php:15-26; the guest alias definition at bootstrap/app.php:19.

Important finding: bootstrap/app.php:15-17 explicitly calls $middleware->validateCsrfTokens(except: ['/survey-api/*']) — CSRF protection is deliberately disabled for all /survey-api/* routes. This is a defensible choice for a token-driven, cross-origin JSON API consumed by a separately-hosted SPA (CSRF tokens are a session-cookie-based defense that doesn't apply cleanly to a stateless, credential-in-body API) — but combined with the total absence of any other authentication mechanism on these routes (see §9), it confirms the lack of auth is a deliberate design choice for this API surface, not an accidental omission of a CSRF middleware alone.

Sequence — public survey-taking request (POST /survey-api/get-questionnaire)

sequenceDiagram
    participant SPA as React SPA - Survey Portal
    participant MW as web middleware group - CSRF exempted here
    participant Ctrl as SurveyApiController::getQuestionnaire
    participant DB as Database

    SPA->>MW: POST /survey-api/get-questionnaire - body: qstr_url - no Authorization header required
    MW->>Ctrl: routed, no auth check
    Ctrl->>Ctrl: Validator::make - qstr_url required, exists:questionnaires,url
    alt validation fails
        Ctrl-->>SPA: 400 - message, errors, res: empty array
    else validation passes
        Ctrl->>DB: Questionnaire::where('url', qstr_url)->first()
        DB-->>Ctrl: questionnaire row
        alt status is 'pending'
            Ctrl->>DB: raw SELECT - likerts JOIN questions - excluding qst 1
            Ctrl->>DB: raw SELECT - netpromoterscores JOIN questions
            Ctrl->>DB: raw SELECT - likerts JOIN questions - qst 1 only, plus integrity_feedbacks subselect
            Ctrl->>DB: Scale::with('scale_options')->get()
            DB-->>Ctrl: questions, scales
        else status is completed/suspended/closed
            Note over Ctrl: questions and scales stay as empty collections
        end
        Ctrl-->>SPA: 200 - res: qstr, questions, scales, suspend
    end

Sequence — session-authenticated admin request (GET /admin/participant-list)

sequenceDiagram
    participant Browser
    participant MW as web middleware group - session, CSRF active
    participant Auth as 'auth' middleware - session guard
    participant Ctrl as AdminController::participantListView
    participant DB as Database
    participant Blade as admin.participant-list view

    Browser->>MW: GET /admin/participant-list - session cookie
    MW->>Auth: Auth::check
    alt not authenticated
        Auth-->>Browser: redirect to /login
    else authenticated
        Auth-->>Ctrl: request proceeds, Auth::user() available
        Ctrl->>DB: raw SELECT - questionnaires JOIN entities - optional et_id filter
        Ctrl->>DB: Entity::all()
        DB-->>Ctrl: participants, entities
        Ctrl->>Blade: view('admin.participant-list', view_data)
        Blade-->>Browser: rendered HTML - Bootstrap table
    end

6. Business Logic

6.1 Questionnaire resolution

SurveyApiController::getQuestionnaire (:63-199) is the entry point every survey session starts from. Its core branch is $qstr->status == 'pending' (:87): only a pending questionnaire returns real question/scale data; any other status (completed, suspended, closed — the full status vocabulary is inferred from the values this codebase itself writes, see §8) returns empty questions/scales arrays, and the frontend is expected to branch on qstr.status to decide what screen to show (the frontend's StatusRoute guard redirects to /completion specifically on status === "completed", per the frontend documentation — suspended/closed states are not explicitly handled by any guard described in the frontend doc, which is a potential gap: a suspended or closed questionnaire would fetch successfully but return no questions, likely rendering a blank quiz screen client-side, since StatusRoute only special-cases "completed").

The question set returned is a union of three separately-queried buckets, each independently shaped: 1. Likert questions (lkrt type), excluding question id 1 (the integrity/attention-check question, handled separately) — :88-104. 2. NPS questions (nps type) — :105-121 — with hardcoded bilingual follow-up question definitions attached client-side-shape (followups array) based on the question's has_followup flag and, specifically for question id 36, a distinct 0-10-scale single-followup shape versus the generic 0-6/7-8/9-10 three-tier followup shape used for all other has_followup NPS questions (:122-161). This means the "which follow-up prompts appear after an NPS answer" logic is not data-driven from any table — it is hardcoded PHP conditional on a magic question id (36), a maintenance risk if question ids ever change across a re-seed. 3. The integrity question (id 1 specifically, type = lkrt) — :163-180 — with its free-text follow-up answer pulled via a correlated subquery against integrity_feedbacks ((SELECT other_text FROM integrity_feedbacks WHERE likert_id = lkrt.id) AS 'other_text', :174).

All three buckets are concatenated and flattened (collect([$q_lkrt, $q_nps, $q_intgr])->flatten(1), :181-185) into a single questions array with no explicit ordering guarantee beyond whatever order the three SQL queries happen to return internally (no ORDER BY clause appears in any of the three queries) — the display order of questions in the resulting array is not deterministic from this code alone; it depends on how the underlying likerts/netpromoterscores rows were inserted (which, per standardSurveyCreator, is itself partially randomized at seed time, see §4.2).

A hardcoded suspend object (:77-85) is included in every response, describing an "attention-check" configuration (scale_id: 1, correct_answer_value: 2, English-only description text). This object's shape strongly implies the frontend is meant to compare a specific answer's score against correct_answer_value and, on mismatch, call POST /survey-api/suspend-questionnaire — but this logic is entirely client-side and not verifiable from this backend alone (see §20).

6.2 Answer submission and completion tracking

Progress is not tracked via any dedicated counter or status field beyond the eventual questionnaires.status = 'completed' transition. Instead, "how many questions has this respondent answered" is answerable at any time by counting likerts/netpromoterscores rows for the questionnaire where score IS NOT NULL AND ans_state IS NOT NULL — this is exactly the inverse of the check submitQuestionnaire runs before allowing completion (:214-215: it blocks completion if any row still has a NULL score or ans_state). This means: - Resume-on-reload (described in the frontend documentation as reconstructing currentQuestion/questionNo from which questions already have saved answers) is only possible because getQuestionnaire returns every question's current score/ans_state/other_text inline (:98,115-117,173-175) — the frontend's setData reducer does the actual "first unanswered question" computation client-side; this backend supplies the raw per-question answer state and nothing more. - There is no dedicated "progress" or "percent complete" field or endpoint anywhere in this backend. Any such UI element (e.g. ProgressBar in the frontend) is computed entirely client-side from the array length and populated-answer count.

submitAnswer (:234-325) persists exactly one answer per call, keyed by ans.ans_id (the primary key of the pre-existing likerts/netpromoterscores row created at seed time by standardSurveyCreator) — there is no "create a new answer row" path in this controller; every answer row a respondent could possibly submit against already exists (with NULL score) from the moment their questionnaire was seeded. This is a deliberate design: the full question set (and its ans_ids) is fixed at seed/randomization time, and submitAnswer only ever UPDATEs.

6.3 Soft-reset vs hard-reset vs suspend vs delete — the exact semantic differences

These four ops-tooling endpoints are easy to conflate; the table below is derived directly from reading each method body (:327-419,374-397):

Endpoint questionnaires.status questionnaires.lang likerts/netpromoterscores answers integrity_feedbacks questionnaires row itself
soft-reset-questionnaire → pending unchanged unchanged (kept as-is) unchanged kept
hard-reset-questionnaire → pending → NULL all nulled (score, ans_state, and for NPS also other_text) deleted (for question id 1's row) kept
suspend-questionnaire → suspended unchanged unchanged unchanged kept
delete-questionnaire n/a n/a all rows deleted deleted deleted

In plain terms: soft-reset just reopens a questionnaire for editing without discarding any prior answers (e.g., to let someone continue after a browser-side issue marked it complete prematurely). Hard-reset is a full rewind to a blank slate while preserving the same row identities (same ans_ids, same randomized question set) — equivalent to "as if this person had never started," but without re-running standardSurveyCreator's randomization. Suspend is a punitive/administrative lock (used, per the hardcoded suspend object in getQuestionnaire, for attention-check failures) that does not touch any answer data — a suspended questionnaire could presumably be un-suspended via soft-reset-questionnaire (both just write to status), though no code path in this backend performs that transition automatically. Delete is the only truly destructive, non-recoverable operation, removing the participant's questionnaire and all associated answer data entirely (their original seed data, e.g. the Questionnaire's emp_id/email/entity_id, is also lost since it lives on the deleted questionnaires row itself).

6.4 Participant seeding and question randomization (AdminController::standardSurveyCreator)

Documented in detail in §4.2. The key business rule: every participant's Likert question set (excluding the two gender-conditional questions and the integrity question) is independently shuffled per participant (:54, ->shuffle()), and two fixed "dummy"/filler questions (ids 2 and 3) are spliced into randomized positions within that shuffled set, with the constraint that the second dummy always appears at least 10 positions after the first (:56-57). This is presumably an anti-straight-lining / attention-check design (dummy questions interspersed unpredictably to catch respondents who click through without reading), consistent with the suspend "attention check" object returned by getQuestionnaire. NPS questions are not shuffled — they are inserted in whatever order the SELECT * FROM questions WHERE id > 3 AND type = 'nps' query returns them (:77), which without an ORDER BY is not guaranteed stable across MySQL versions/configurations but is in practice usually primary-key order.


7. API Layer

/survey-api/* — public, no authentication (all POST, all registered routes/web.php:28-37)

Method Path Controller@action Auth Request body Response (200) Response (400)
POST /survey-api/get-style-metadata SurveyApiController::getStyleMetadata None (none read) Hardcoded empty-string branding stub — see §4.1 n/a (always 200)
POST /survey-api/get-url-by-identifier SurveyApiController::getUrlByIdentifier None identifier_type (emp_id|email), identifier_value (string) {message, res: {qstr_url}} {message, errors, res: []}
POST /survey-api/get-questionnaire SurveyApiController::getQuestionnaire None qstr_url (string, must exist) {message, res: {qstr, questions, scales, suspend}} {message, errors, res: []}
POST /survey-api/submit-answer SurveyApiController::submitAnswer None qstr_url, ans: {type: lkrt\|nps, score, ans_state, ans_id, qst_id, other_text?} {message, res: {ans_id}} {message, errors, res: []}
POST /survey-api/submit-questionnaire SurveyApiController::submitQuestionnaire None qstr_url {message, res: {qstr_url}} {message, errors: [], res: []} (400 if any answer still unset)
POST /survey-api/soft-reset-questionnaire SurveyApiController::softResetQuestionnaire None qstr_url {message, res: {qstr_url}} {message, errors, res: []}
POST /survey-api/hard-reset-questionnaire SurveyApiController::hardResetQuestionnaire None qstr_url {message, res: {qstr_url}} {message, errors, res: []}
POST /survey-api/delete-questionnaire SurveyApiController::deleteQuestionnaire None qstr_url {message, res: {qstr_url}} {message, errors, res: []}
POST /survey-api/suspend-questionnaire SurveyApiController::suspendQuestionnaire None qstr_url {message, res: {qstr_url}} {message, errors, res: []}
POST /survey-api/select-language SurveyApiController::selectLanguage None qstr_url, lang (eng|urdu) {message, res: {qstr_url, lang}} {message, errors, res: []}

Frontend cross-reference: per docs/survey-portal-frontend-documentation.md §9, the frontend's questionsService.js calls exactly four of these ten endpoints: get-questionnaire, submit-answer, submit-questionnaire, and select-language. The remaining six — get-style-metadata, get-url-by-identifier, soft-reset-questionnaire, hard-reset-questionnaire, delete-questionnaire, suspend-questionnaire — are never called by the documented frontend codebase. Given their shape (ops-style state-mutation on an arbitrary qstr_url, no admin session required), the most likely explanation is that they are operations tooling invoked directly by consultancy staff via Postman/curl/a script when managing a live survey wave (e.g., resetting a respondent who got stuck, or manually suspending someone flagged for low-quality answers) — but this is not confirmed by any code in this repository; no admin Blade view or JS in this backend calls them either. get-style-metadata in particular returns only a hardcoded stub (see §4.1), suggesting it is scaffolding for a not-yet-implemented per-client branding feature rather than an in-use ops tool. See §20.

Resolves a frontend-side open question: the frontend documentation flags that its Axios interceptor reads a bearer token from localStorage.getItem("authToken") on every request but that nothing in the frontend codebase ever writes that key (docs/survey-portal-frontend-documentation.md §11). Having now read this backend's route file and every SurveyApiController method in full, that mystery is resolved from the backend side: none of the /survey-api/* routes check for a Bearer token, a Sanctum guard, or any Authorization header at all — there is no auth:sanctum (Sanctum is not even a dependency in composer.json) or any other auth middleware wrapping these routes, and no controller method reads $request->bearerToken() or $request->header('Authorization') anywhere. The frontend's dead authToken interceptor code is therefore consistent with — and fully explained by — this backend never expecting or validating one for this API surface.

/admin/* — session-authenticated (all under Route::middleware(['auth']), routes/web.php:15-26)

Method Path Controller@action Auth Request body / params Response
GET /admin AdminController::redirector session — 302 → /admin/survey-summary
GET /admin/survey-summary AdminController::surveySummaryView session query: et_id?, demg? admin.survey-summary Blade view
GET /admin/participant-list AdminController::participantListView session query: et_id? admin.participant-list Blade view
GET /admin/add-participant AdminController::addParticipantView session — admin.add-participant Blade view
POST /admin/add-participant AdminController::addParticipantRequest session emp_id, name, email, entity_id, demg1, demg2, demg3 redirect back with flash formsuccess/formerror
GET /admin/update-participant/{id} AdminController::updateParticipantView (routed lowercase; method defined UpdateParticipantView) session route param id admin.update-participant Blade view
POST /admin/update-participant/{id} AdminController::updateParticipantRequest (routed lowercase; method defined UpdateParticipantRequest) session route param id, same fields as add redirect back with flash
GET /admin/send-email AdminController::sendEmailView session — admin.send-email Blade view
POST /admin/send-email AdminController::sendEmailRequest session email_subject, email_body, qids?, specids? redirect back with flash; triggers SendGrid bulk send
GET /admin/send-whatsapp AdminController::sendWhatsappRequest session — response()->json({formsuccess}) or the raw exception object on failure (see §13)

Other routes (routes/web.php, not namespaced under /admin or /survey-api)

Method Path Controller@action Auth Notes
GET/POST /login Auth\LoginController (via Auth::routes(...)) guest middleware Stock Laravel UI login
POST /logout Auth\LoginController (via Auth::routes(...)) auth Stock Laravel UI logout
GET /seed AdminController::seedFunction None Public, unauthenticated seeding endpoint — see §15
GET /mail/send AdminController::standardEmailSender None Broken — no such method exists on AdminController, see §4.2
GET / SurveyController::surveyMainView None Serves the pre-built SPA shell Blade view
GET /logout inline closure (routes/web.php:39) None (calls Auth::logout() regardless) A second, redundant logout route distinct from the Auth::routes()-registered POST /logout

8. Database Interaction

Schema provenance

There is no database/migrations/ directory in this repository (database/ contains only a .gitignore file). Like its sibling "EES - Report backend," this application runs against an externally-managed, pre-existing database schema — the tables below are reverse-engineered entirely from (a) the 5 Eloquent models' declared $table/$fillable/relationships, and (b) every column referenced in raw SQL strings across SurveyApiController.php and AdminController.php. Each table is marked Confirmed (backed by an Eloquent model with explicit $table/$fillable, or a SELECT */INSERT that implies the full column list is known) or Inferred (reconstructed purely from SELECT/WHERE/UPDATE column references scattered across the codebase — the true column list, types, and nullability could include columns never referenced by this backend's code, and this document cannot know about them).

Entity-Relationship Diagram

erDiagram
    ENTITIES ||--o{ QUESTIONNAIRES : "entity_id"
    QUESTIONNAIRES ||--o{ LIKERTS : "questionnaire_id"
    QUESTIONNAIRES ||--o{ NETPROMOTERSCORES : "questionnaire_id"
    QUESTIONS ||--o{ LIKERTS : "question_id"
    QUESTIONS ||--o{ NETPROMOTERSCORES : "question_id"
    LIKERTS ||--o| INTEGRITY_FEEDBACKS : "likert_id, question_id=1 only"
    SCALES ||--o{ SCALE_OPTIONS : "scale_id"
    QUESTIONS }o--|| SCALES : "scale_id"
    USERS {
        bigint id PK
        string name
        string email
        string password
        int role_id "inferred, Blade-view only"
    }

    ENTITIES {
        bigint id PK
        string name
    }

    QUESTIONNAIRES {
        bigint id PK
        string emp_id
        string email
        string phone
        string name
        bigint entity_id FK
        string url "sha1 token, invite link credential"
        string status "pending, completed, suspended, closed"
        string lang "eng, urdu, nullable"
        string demg1
        string demg2
        string demg3
        timestamp created_at
        timestamp updated_at
        timestamp submitted_at "in fillable, usage unconfirmed"
    }

    QUESTIONS {
        bigint id PK
        string type "lkrt, nps"
        bigint scale_id FK
        boolean mandatory
        boolean has_followup
        text qst_eng
        text qst_urdu
    }

    LIKERTS {
        bigint id PK
        bigint questionnaire_id FK
        bigint question_id FK
        int score "1-5, nullable until answered"
        string ans_state "yes, no, nullable until answered"
    }

    NETPROMOTERSCORES {
        bigint id PK
        bigint questionnaire_id FK
        bigint question_id FK
        int score "0-10, nullable until answered"
        text other_text "nullable"
        string ans_state "yes, no, nullable until answered"
    }

    INTEGRITY_FEEDBACKS {
        bigint id PK
        bigint likert_id FK
        text other_text
    }

    SCALES {
        bigint id PK
    }

    SCALE_OPTIONS {
        bigint id PK
        bigint scale_id FK
    }

Table-by-table reference

questionnaires — Confirmed (Eloquent model with explicit $table/$fillable)

Source: app/Models/Questionnaire.php:10-12.

Column Type (inferred) Nullable Notes
id bigint, PK No Implicit Eloquent primary key
emp_id string Confirmed nullable-in-practice (used as unique-ish key) AdminController.php:44,166,206
email string Yes (WhatsApp-only participants have email IS NULL, AdminController.php:309) Normalized lowercase, whitespace-stripped before insert (AdminController.php:40,158)
phone string Yes AdminController.php:311
name string No (used in email personalization) Title-cased before insert (AdminController.php:43)
entity_id bigint, FK → entities.id No AdminController.php:139 (INNER JOIN)
url string No, effectively unique (used as the sole invite-link lookup key) sha1($salt . '___' . $emp_id) — see §9
status string No, default 'pending' Observed values: pending, completed, suspended, closed (the last inferred only from AdminController.php:104-107,116-119 SQL that counts a closed bucket — no code path in this backend ever sets status = 'closed', so that transition must happen via direct DB manipulation or a process outside this codebase)
lang string, nullable Yes eng | urdu, set via selectLanguage, cleared to NULL by hardResetQuestionnaire
demg1 / demg2 / demg3 string Confirmed present, nullability not determinable Free-form demographic tags (e.g. 'Male'/'Female' for demg1 in the seed data, AdminController.php:23-26); used for surveySummaryView's grouping
created_at / updated_at timestamp Standard Eloquent timestamps In $fillable (unusual — normally excluded and auto-managed)
submitted_at timestamp Yes Declared in $fillable (Questionnaire.php:12) but no code in this backend ever writes to it — submitQuestionnaire only sets status = 'completed' via raw SQL (SurveyApiController.php:223), bypassing Eloquent (and thus bypassing $fillable entirely for that write). Likely a vestigial/planned column.

entities — Inferred (no $fillable, referenced only via raw SQL and Entity::all())

Source: app/Models/Entity.php (empty model — no $table, $fillable, or relationships declared, so Eloquent infers the table name entities and allows mass-create of no columns), AdminController.php:139-141,150,190.

Column Type (inferred) Notes
id bigint, PK entity_id FK target
name string Displayed in the admin nav and entity filter dropdown (AdminController.php:127,141)

questions — Inferred (no Eloquent model exists for this table at all)

Source: raw SQL only — SurveyApiController.php:88-104,105-121,163-180; AdminController.php:54-59,77.

Column Type (inferred) Notes
id bigint, PK Magic ids referenced in code: 1 = integrity question, 2/3 = "dummy" filler questions, 36 = the NPS question with the special single-followup shape, 39/41 = gender-conditional Likert questions
type string Observed values: lkrt, nps
scale_id bigint, FK → scales.id SurveyApiController.php:93,110,168
mandatory boolean (0/1) SurveyApiController.php:94,111,169
has_followup boolean (0/1) Drives the hardcoded NPS follow-up logic, SurveyApiController.php:95,112,123,170
qst_eng / qst_urdu text Bilingual question text

likerts — Inferred (referenced only via raw SQL / query builder, no model)

Source: SurveyApiController.php:88-104,163-180,214,234-283,362,388; AdminController.php:62-65,68-74,83-85.

Column Type (inferred) Notes
id bigint, PK ans_id in API payloads
questionnaire_id bigint, FK → questionnaires.id
question_id bigint, FK → questions.id
score int, nullable, 1-5 NULL until answered; validated between:1,5 on write (SurveyApiController.php:250)
ans_state string, nullable Observed values: yes, no — semantics not confirmable from this codebase alone (possibly "did the respondent actively answer vs. skip," or a data-quality flag)

No started_at/answered_at/reviewed_at columns are actually written despite being referenced in commented-out code (SurveyApiController.php:267-269,305-307) — these appear to be vestigial/planned timestamp-tracking columns that were never wired up; see §17.

netpromoterscores — Inferred

Source: SurveyApiController.php:105-161,215,285-316,364,389; AdminController.php:77-81.

Column Type (inferred) Notes
id bigint, PK ans_id in NPS-type API payloads
questionnaire_id bigint, FK → questionnaires.id
question_id bigint, FK → questions.id
score int, nullable, 0-10 Validated between:0,10 on write (SurveyApiController.php:287)
other_text text, nullable Free-text follow-up answer
ans_state string, nullable Same yes/no pattern as likerts.ans_state

integrity_feedbacks — Inferred

Source: SurveyApiController.php:174,272-276,363,387.

Column Type (inferred) Notes
id bigint, PK Not directly referenced by name, but implied by Eloquent-style table conventions
likert_id bigint, FK → likerts.id Always the id of the likerts row for question_id = 1
other_text text The integrity/attention-check question's free-text answer

scales — Confirmed (Eloquent model, relationship declared)

Source: app/Models/Scale.php:8-17. No $fillable or explicit $table is declared, so Eloquent infers table name scales by convention. Columns beyond id are not directly referenced anywhere in the read code (the frontend consumes whatever Scale::with('scale_options')->get() serializes, which is the full row) — see §20.

scale_options — Confirmed (Eloquent model, relationship declared)

Source: app/Models/ScaleOptions.php:8-17. Same caveat as scales — belongsTo(Scale::class) implies a scale_id FK column by Eloquent convention, but no other columns are referenced in code read during this pass.

users — Partially confirmed (Eloquent model, stock Laravel Authenticatable)

Source: app/Models/User.php:20-24 ($fillable: name, email, password), plus $hidden (password, remember_token) and casts (email_verified_at → datetime, password → hashed). One additional column is used but not declared on the model at all: role_id, referenced in resources/views/layouts/admin.blade.php:27 (@if(Auth::user()->role_id == 1)) to gate the "Add Participant"/"Send Email" nav links to presumably-admin-role users. This column's existence is confirmed only by this Blade usage — its type, full value vocabulary (only 1 is checked; other values are unknown), and whether it has a default are not determinable from this codebase.

Data access pattern summary

Access pattern Where used
Eloquent Model::create() / ::where()->first() / ::where()->update() Questionnaire (both controllers), Entity::all() (AdminController), Scale::with('scale_options')->get() (SurveyApiController)
Raw DB::select(...) with string-interpolated SQL The overwhelming majority of reads in both controllers — likerts, netpromoterscores, integrity_feedbacks, questions, and even questionnaires/entities in several AdminController methods
Raw DB::update(...) / DB::delete(...) with string-interpolated SQL All SurveyApiController state-mutation methods (submitQuestionnaire, softResetQuestionnaire, hardResetQuestionnaire, deleteQuestionnaire, suspendQuestionnaire, selectLanguage)
Query builder DB::table(...)->insert() / ->update() / ->updateOrInsert() standardSurveyCreator's bulk likerts/netpromoterscores inserts; submitAnswer's likerts/netpromoterscores updates and integrity_feedbacks upsert

There is no use of Eloquent relationships for traversal beyond Scale::scale_options — even though Questionnaire conceptually has many likerts/netpromoterscores, no hasMany relationship is declared on the Questionnaire model, and every such traversal is done via a fresh raw SQL join instead.


9. Authentication & Authorization

This backend has two entirely separate, non-overlapping auth models for its two route groups.

/admin/* — session/cookie authentication (Laravel's stock auth guard)

  • Login is handled by the stock laravel/ui-generated Auth\LoginController (app/Http/Controllers/Auth/LoginController.php), using Laravel's default web guard (config/auth.php:39-42, session-driver, App\Models\User Eloquent provider).
  • routes/web.php:15-26 wraps every /admin/* route in Route::middleware(['auth']), which redirects unauthenticated requests to /login.
  • Authorization (as opposed to authentication) is minimal and Blade-view-only: resources/views/layouts/admin.blade.php:27 checks Auth::user()->role_id == 1 to conditionally show "Add Participant" and "Send Email" nav links. This is presentation-layer gating only — the underlying routes GET/POST /admin/add-participant and GET/POST /admin/send-email have no server-side role check in AdminController itself; any authenticated user (regardless of role_id) who navigates directly to those URLs can add participants or send bulk email, even if the nav link is hidden from them. This is a real authorization gap — see §15.
  • Registration, password-reset, and email-verification are all disabled (Auth::routes(['reset' => false, 'register' => false, 'verify'=>false]), routes/web.php:8) — new admin users must be provisioned by some means outside this codebase (direct DB insert, tinker, a seeder not present in this repo). How the first/any admin user account gets created is not determinable from this codebase — see §20.

/survey-api/* — no authentication at all (headline finding)

Every one of the ten /survey-api/* routes is completely unauthenticated. This was verified two ways: 1. Route-file evidence: none of these routes appear inside the Route::middleware(['auth'])->group(...) block (routes/web.php:15-26) — they are registered as bare Route::post(...) calls at the top level of the file (routes/web.php:28-37), with no middleware array specified at all. 2. Controller-body evidence: every method in SurveyApiController.php was read in full. None of the ten methods calls Auth::check(), $request->user(), $request->bearerToken(), or any authorization gate of any kind. The only thing standing between a caller and any of these endpoints' effects is knowledge of a valid qstr_url value — a sha1 hash string that identifies (and is the sole credential for) one specific questionnaire.

Practical implication: because qstr_url is the only credential, and because six of the ten endpoints are pure state-mutation operations (submit-answer, submit-questionnaire, soft-reset-questionnaire, hard-reset-questionnaire, delete-questionnaire, suspend-questionnaire) that accept only qstr_url (plus, for submit-answer, an answer payload) as input, anyone who obtains or guesses a single respondent's invite-link token can, without any further authentication: - Read that respondent's full question set and any answers already given (get-questionnaire). - Overwrite any of that respondent's answers (submit-answer) — with no verification that the submitted ans_id actually belongs to the questionnaire named by qstr_url (see §4.1), meaning a caller who knows any valid ans_id (sequential integers, likely easy to enumerate) combined with any valid qstr_url could potentially tamper with a different respondent's answer row, since the UPDATE ... WHERE id = <ans_id> statements (SurveyApiController.php:264,301) do not also constrain WHERE questionnaire_id = <qstr->id>. - Force-complete, soft-reset, hard-reset (wiping all their answers), suspend, or permanently delete that respondent's entire questionnaire and answer history — each with a single POST request and no confirmation step.

How exposed this actually is depends entirely on how unguessable qstr_url values are (see §9's final subsection below) — but even under the most favorable assumption (tokens are cryptographically unguessable), the complete absence of any server-side check that the caller is the intended respondent (no session, no per-request secret beyond the URL itself, no rate limiting) means a leaked or logged URL (e.g., in a browser history, a referrer header, a proxy log, or a forwarded email) grants full read/write/delete control over that person's survey response indefinitely, with no way to detect or audit who performed the action (see §13 — no audit logging exists on any of these state changes).

The questionnaire URL token as the only credential

AdminController::standardSurveyCreator generates each questionnaire's url as sha1(Config::get('app.salt') . '___' . $o['emp_id']) (AdminController.php:45). Two things about this are significant:

  1. app.salt is referenced but never defined. A full search of config/app.php (and every other config file in this codebase) finds no 'salt' => ... key declared anywhere — config/app.php defines client_prefix, client_name, client_clr_prm_rgb/hex, client_clr_sec_rgb/hex (all reading their own like-named env vars) but no salt key at all (config/app.php:5-10). Config::get('app.salt') will therefore return null unless an .env value is separately wired into a salt key that does not exist in the checked-in config/app.php — meaning, as this codebase stands, the token effectively reduces to sha1('___' . $emp_id) if no undocumented config addition exists in the deployed environment. Since emp_id values observed in the seed data are simple, low-entropy strings (e.g. 'int0001', AdminController.php:23), a token derived from a null-salt sha1 of a predictable emp_id pattern would be straightforwardly guessable/brute-forceable by an attacker with any knowledge of the organization's employee-id numbering scheme. This is a critical finding, though it carries the caveat that a production .env/deployment could plausibly define an additional config merge not visible in this repository — see §20.
  2. Even with a strong salt, the token is deterministic per emp_id, not randomly generated per questionnaire instance — the same emp_id will always produce the same url for a given salt, for the lifetime of the deployment (there is no per-survey-wave nonce or expiry mixed into the hash).

Resolves a frontend-side open question

As detailed in §7, the frontend's dead authToken-from-localStorage interceptor code (never written to anywhere in the frontend) is now fully explained: this backend's /survey-api/* surface never expects, reads, or validates a bearer token of any kind. The interceptor is either boilerplate copied from a shared template or scaffolding for a feature that was never implemented on either side.


10. Background Processing

Unlike the sibling "EES - Report backend" (which the existing documentation notes has no background processing at all), this backend does have a real queued job, though it is currently not wired into any live code path.

App\Jobs\SendWhatsAppMessageJob (app/Jobs/SendWhatsAppMessageJob.php)

  • Implements ShouldQueue (:12), using the standard Dispatchable, InteractsWithQueue, Queueable, SerializesModels traits (:14).
  • Constructor takes $to, $templateName, $parameters (:20-25) — plain scalar/array properties, no Eloquent models are serialized, so there is no risk of stale-model-on-dequeue issues.
  • handle(WhatsAppTemplateService $whatsAppService) (:27-30) — Laravel's queue worker resolves WhatsAppTemplateService via the container at execution time and calls sendTemplateMessage($this->to, $this->templateName, $this->parameters).
  • Dispatch site: App\Services\WhatsAppTemplateService::sendBulkTemplateMessages(array $recipients, string $templateName, array $parametersPerUser) (app/Services/WhatsAppTemplateService.php:36-42) loops over recipients and calls SendWhatsAppMessageJob::dispatch($number, $templateName, $params) for each.
  • However, sendBulkTemplateMessages is never called anywhere in this codebase. The only controller method that sends WhatsApp messages, AdminController::sendWhatsappRequest (app/Http/Controllers/AdminController.php:294-338), calls $whatsapp->sendTemplateMessage(...) directly and synchronously, in a foreach loop (:324-327) — the non-queued method on the same service class. This means, as the code currently stands, no WhatsApp message actually goes through the queue — SendWhatsAppMessageJob and its dispatch wrapper are dead code from a live-traffic perspective, present but unused. Any future work that wants bulk WhatsApp sends to be properly backgrounded (avoiding the synchronous foreach blocking the HTTP request/response cycle, and avoiding the risk of a Twilio rate-limit or timeout mid-loop) should switch sendWhatsappRequest to call sendBulkTemplateMessages instead.

Queue configuration (config/queue.php)

  • Default connection: env('QUEUE_CONNECTION', 'database') (config/queue.php:16) — the .env.example in this repo also sets QUEUE_CONNECTION=database explicitly. This means, by default, queued jobs are persisted to a jobs database table (config/queue.php:37-44, table name configurable via DB_QUEUE_TABLE, default 'jobs') and require a running php artisan queue:work (or queue:listen) process to actually execute — there is no evidence in this repository of such a worker process being configured for deployment (no Procfile, no supervisor config, no CI/CD deploy script present in this checkout).
  • Failed-job handling: env('QUEUE_FAILED_DRIVER', 'database-uuids') (config/queue.php:107) — failed jobs are logged to a failed_jobs table by default.
  • No Laravel Horizon, no custom queue worker process management, and no scheduled jobs (app/Console/Kernel.php does not exist in this Laravel 11 skeleton style — scheduling, if any, would be registered via routes/console.php, which was not found to contain any Schedule:: calls during this pass).

Background processing flow (as designed, not as currently exercised)

flowchart LR
    A["AdminController::sendWhatsappRequest - CURRENT: calls sendTemplateMessage directly, synchronous loop"] -.->|"NOT currently called"| B["WhatsAppTemplateService::sendBulkTemplateMessages"]
    B --> C["SendWhatsAppMessageJob::dispatch - per recipient"]
    C --> D[("jobs table - QUEUE_CONNECTION=database")]
    D --> E["php artisan queue:work - worker process, not evidenced in this repo"]
    E --> F["SendWhatsAppMessageJob::handle"]
    F --> G["WhatsAppTemplateService::sendTemplateMessage"]
    G --> H[["Twilio API"]]
    A -->|"CURRENT live path"| G

11. Integrations

SendGrid (outbound email)

  • Library: sendgrid/sendgrid ^8.1 (composer.json:13).
  • Wiring: App\Services\SendGridMailService::sendBulkEmail($sendgridPersonalization, $content = '') (app/Services/SendGridMailService.php:8-33) — a static method, instantiated with new \SendGrid($sendgridApiKey) where $sendgridApiKey = \Config::get('services.sendgrid_api_key') (:11, sourced from config/services.php:17 → env('SENDGRID_API_KEY')).
  • Behavior: chunks the incoming Personalization array into batches of 1000 (array_chunk(..., 1000, true), :10 — SendGrid's per-request personalization limit), builds one SendGridMail object per chunk with a fixed From address (survey@engageconsulting.biz, "Surveyor", :12-13), HTML content, and sends each chunk via $sendgrid->send($email). On a caught Exception (note: the catch (Exception $e) clause at :25 catches the global \Exception only if this file's use imports resolve it correctly — no use Exception; import is declared at the top of this file, so catch (Exception $e) actually references App\Services\Exception, a class that does not exist, which would itself raise a fatal error rather than gracefully returning the error array — see §17) it returns ['status' => false, 'message' => '...']; on success (all chunks sent without throwing), it returns ['status' => true].
  • Caller: AdminController::sendEmailRequest (app/Http/Controllers/AdminController.php:230-292) — builds one Personalization per pending, emailed participant with %name%/%url% substitution tags, and passes the admin-supplied email_subject/email_body from the Blade form.

Twilio (WhatsApp messaging)

  • Library: twilio/sdk ^8.8 (composer.json:14).
  • Wiring: App\Services\WhatsAppTemplateService (app/Services/WhatsAppTemplateService.php:7-43) constructs a Twilio\Rest\Client using env('TWILIO_SID')/env('TWILIO_AUTH_TOKEN') read directly via the env() helper in the service constructor, not via a config/services.php entry (:14 — this bypasses Laravel's config-caching best practice; env() calls outside config/*.php files will not reflect cached config in production if config:cache is run, a real operational risk, see §17). $this->from = env('TWILIO_WHATSAPP_FROM') (:15).
  • Behavior: sendTemplateMessage(string $to, string $templateName, array $parameters = []): bool (:18-34) sends a WhatsApp Business template message via $this->twilio->messages->create("whatsapp:$to", ['from' => $this->from, 'contentSid' => $templateName, 'contentVariables' => json_encode($parameters)]), catching exceptions and logging via \Log::error(...), returning true/false.
  • Caller: AdminController::sendWhatsappRequest (see §4.2 and §10 for the synchronous-vs-queued discrepancy). The template SID is hardcoded ('HX76f2e919fe960867499232ef15b99700', AdminController.php:321), as is a $due_date string ('8th May 2026', :322) — both are survey-wave-specific values baked into the controller rather than parameterized via the request or config, meaning this endpoint must be code-edited for each new WhatsApp invite campaign.

Integration flow

flowchart TB
    Admin["Consultancy staff - /admin/send-email or /admin/send-whatsapp"]
    SGSvc["SendGridMailService::sendBulkEmail"]
    WASvc["WhatsAppTemplateService::sendTemplateMessage"]
    SG[["SendGrid API"]]
    TW[["Twilio API - WhatsApp Business"]]
    Cfg["config/services.php - SENDGRID_API_KEY"]
    Env["env - TWILIO_SID, TWILIO_AUTH_TOKEN, TWILIO_WHATSAPP_FROM"]

    Admin -->|"POST /admin/send-email"| SGSvc --> Cfg
    SGSvc -->|"HTTPS, chunks of 1000"| SG
    Admin -->|"GET /admin/send-whatsapp"| WASvc --> Env
    WASvc -->|"HTTPS, one message per recipient, synchronous"| TW

12. Configuration

.env.example — as committed, this file is the stock, unmodified Laravel skeleton default

Source: .env.example (full contents read). It declares only the generic Laravel keys (APP_NAME, APP_ENV, APP_KEY, APP_DEBUG, APP_TIMEZONE, APP_URL, DB_CONNECTION=sqlite, SESSION_*, QUEUE_CONNECTION=database, CACHE_STORE=database, MAIL_*, AWS_*). It documents none of the custom environment variables this application actually reads — a significant onboarding gap. The table below is reconstructed from every env(...)/Config::get(...) call found across config/*.php and the application code itself.

Variable Read by Purpose Default if unset
APP_NAME, APP_ENV, APP_KEY, APP_DEBUG, APP_URL, APP_TIMEZONE, APP_LOCALE, APP_FALLBACK_LOCALE config/app.php (stock keys) Standard Laravel bootstrap config Laravel defaults
CLIENT_PREFIX config/app.php:5 Per-client branding prefix, used in the admin layout <title> (resources/views/layouts/admin.blade.php:8) literal string 'CLIENT_PREFIX' (not a real fallback — will render literally if unset)
CLIENT_NAME config/app.php:6 Per-client display name literal string 'CLIENT_NAME'
CLIENT_COLOR_PRIMARY_RGB / CLIENT_COLOR_PRIMARY_HEX / CLIENT_COLOR_SECONDARY_RGB / CLIENT_COLOR_SECONDARY_HEX config/app.php:7-10 Per-client branding colors — not currently consumed anywhere in code (SurveyApiController::getStyleMetadata returns hardcoded empty strings instead of reading these, see §4.1) literal placeholder strings
SENDGRID_API_KEY config/services.php:17 SendGrid API authentication null
TWILIO_SID, TWILIO_AUTH_TOKEN, TWILIO_WHATSAPP_FROM app/Services/WhatsAppTemplateService.php:14-15 (direct env() calls, bypassing config/services.php) Twilio WhatsApp API authentication + sender number null (would fail at Client construction or send time)
DB_CONNECTION, DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD config/database.php Database connection — production almost certainly uses mysql given raw SQL syntax (backtick-free but MySQL-flavored LIMIT/string concatenation patterns) even though .env.example defaults to sqlite sqlite (local dev only)
QUEUE_CONNECTION config/queue.php:16 Queue backend selection database
SESSION_DRIVER, SESSION_LIFETIME, etc. config/session.php Admin-portal session config database, 120 minutes
MAIL_* config/mail.php Not actively used for transactional email (SendGrid SDK is used directly instead, bypassing Laravel's Mail facade entirely) log mailer
(no key found for 'salt') AdminController.php:45 (Config::get('app.salt')) Questionnaire invite-URL token generation — see §9 for the security implication of this key not existing in config/app.php at all null

config/cors.php — fully permissive

'paths' => ['*'],
'allowed_methods' => ['*'],
'allowed_origins' => ['*'],
'allowed_headers' => ['*'],
'supports_credentials' => false,
Source: config/cors.php:18-32. Every path, every HTTP method, and every origin is allowed. Combined with supports_credentials: false, this is a defensible configuration for a public, tokenless JSON API meant to be called from any origin (matching the "no auth at all" design of /survey-api/*) — but it applies globally to every route in the app, including /admin/*, though session-cookie-based auth combined with supports_credentials: false means a cross-origin browser request to /admin/* could not actually carry the session cookie needed to authenticate, limiting the practical exposure there.

config/session.php / config/cache.php / config/filesystems.php

Stock Laravel 11 defaults, no custom keys added. Session driver defaults to database (requires a sessions table — not explicitly confirmed to exist via migrations, but implied necessary for /admin/* login to function in production). Cache defaults to database (a cache table). Filesystem defaults to local disk (storage/app/private), with s3 configured but not confirmed to be in active use anywhere in this codebase (no Storage::disk('s3') calls found).


13. Logging & Error Handling

  • No custom exception handler. This Laravel 11 skeleton's bootstrap/app.php:21-23 has an empty withExceptions(function (Exceptions $exceptions) { // }) closure — no custom exception rendering, no API-specific JSON-error normalization, no Sentry/Bugsnag wiring. All uncaught exceptions fall through to Laravel's default behavior (a JSON 500 response for requests expecting JSON, an Ignition/Whoops debug page if APP_DEBUG=true, or a generic error page otherwise).
  • Validation errors are handled consistently within SurveyApiController — every method wraps its logic in an explicit Validator::make(...)->fails() check and returns a hand-built {"message": ..., "errors": [...], "res": []} 400 response rather than letting Laravel's automatic ValidationException JSON response fire. This is a deliberate, consistent pattern across all 9 validated methods in that controller.
  • AdminController::sendWhatsappRequest has a broken error-handling path. Its catch (\Exception $e) block (app/Http/Controllers/AdminController.php:331-337) calls Log::info($e) (not Log::error — logging an exception object at info severity, which most log-level filters would not surface as urgently as an error) and then return $e; (:335) — returning the raw \Exception object directly as the controller's return value. Laravel's routing layer cannot render an Exception object as an HTTP response, so this would itself throw a TypeError at the framework level rather than returning any kind of graceful error response to the client. The return response()->json(['formerror' => ...]) line immediately after (:336) is unreachable dead code — it can never execute because the return $e; above it always returns first. See §17, finding #2.
  • SendGridMailService::sendBulkEmail's catch block likely never fires as written. As noted in §11, catch (Exception $e) (app/Services/SendGridMailService.php:25) has no corresponding use Exception; import in this file's namespace (App\Services), so the caught type resolves to the non-existent App\Services\Exception — meaning a real SendGrid API exception (which would be \Exception or a subclass in the global namespace) would not be caught by this clause at all, and would instead propagate uncaught up through AdminController::sendEmailRequest's own outer try { ... } catch (\Exception $e) block (AdminController.php:239,289), which correctly imports the global \Exception implicitly (no use needed, since it's referenced with the full \Exception leading-backslash syntax) — so in practice the outer catch in AdminController still handles it gracefully, but the inner catch in SendGridMailService is dead code.
  • No structured/centralized logging. \Log::error(...) is used once, in WhatsAppTemplateService::sendTemplateMessage (app/Services/WhatsAppTemplateService.php:31). No other file in this codebase writes to the log at all — there is no logging of admin actions (participant add/update, email/WhatsApp sends), no logging of /survey-api/* state-mutating calls (reset/suspend/delete), and no request/response audit trail of any kind. Combined with the complete lack of authentication on /survey-api/* (see §9/§15), this means a delete-questionnaire or hard-reset call leaves no trace of who invoked it or when, beyond whatever the web server's own access logs (outside this codebase) might capture.
  • config/logging.php was not present among the files enumerated for this pass (the standard Laravel LOG_CHANNEL=stack/LOG_STACK=single values from .env.example imply the default storage/logs/laravel.log single-file channel is in effect) — see §20.

14. Performance Considerations

  • No caching layer is used anywhere in the application code (Cache:: facade is never called in any controller/service read during this pass) — every getQuestionnaire call re-runs three raw SQL queries plus a Scale::with('scale_options')->get() (which loads every scale and scale-option row in the database on every single request, not just the ones relevant to the current questionnaire's questions — SurveyApiController.php:187). For a survey with many scale types this is a full-table read on every question-fetch, though the tables are likely small (a handful of Likert-scale definitions) so the practical impact is probably minor.
  • No pagination anywhere. AdminController::participantListView loads all matching questionnaires rows (optionally filtered by entity_id) in a single query with no LIMIT/OFFSET (AdminController.php:139) — for a large survey population (thousands of employees), this could become a slow, memory-heavy admin page load with no way to page through results.
  • N+1-shaped risk in getQuestionnaire: the integrity-question query embeds a correlated scalar subquery per row (SurveyApiController.php:174) rather than a LEFT JOIN — for the single integrity question this is a non-issue (one row), but it is a pattern that would not scale if reused elsewhere.
  • standardSurveyCreator's per-participant question-assignment loop runs multiple DB::table(...)->insert() calls per question rather than a single batched insert([...]) call with all rows at once (AdminController.php:61-65,77-81 insert one row per loop iteration, not a single multi-row insert) — for a bulk import of many participants via addParticipantRequest/a batch seed, this means dozens of individual INSERT statements per participant instead of a handful of batched ones, meaningfully slower for large participant lists.
  • ini_set('max_execution_time', 600000) (AdminController.php:20,297 — 600,000 seconds, roughly 166 hours, almost certainly meant to be 600 seconds given the comment pattern and the later reset to 6000/600 in the same methods) — this looks like a typo (an extra 000) rather than an intentional near-infinite execution allowance; see §17.
  • No database indexes can be confirmed or denied since there are no migrations in this repository — whether questionnaires.url (the sole lookup key for every /survey-api/* call) is indexed is unknown; if it is not, every get-questionnaire/submit-answer/etc. call would perform a full table scan, which would degrade significantly as the questionnaires table grows across survey waves.

15. Security

This section consolidates every security-relevant finding surfaced elsewhere in this document, plus additional issues found only during a security-focused re-read.

Headline finding: the entire /survey-api/* surface is unauthenticated

Covered in full in §9. Summary: routes/web.php:28-37 registers all 10 survey-taking endpoints with no middleware; SurveyApiController.php's method bodies (all 444 lines read in full) contain no authentication or authorization check of any kind; the sole "credential" is knowledge of a qstr_url token, which (per §9) is generated from sha1(Config::get('app.salt') . '___' . $emp_id) where app.salt is not defined anywhere in config/app.php (config/app.php:5-10 — the only client_* keys are present, no salt key exists). This means, absent an undocumented production config addition, questionnaire URLs are derived from a predictable emp_id pattern with no actual secret salt mixed in, making them plausibly guessable/enumerable.

Consequences, restated precisely: - Any of submit-answer, submit-questionnaire, soft-reset-questionnaire, hard-reset-questionnaire, suspend-questionnaire, delete-questionnaire can be invoked by anyone who has (or derives) a qstr_url, with no session, no API key, and no rate limiting. - submitAnswer's UPDATE ... WHERE id = <ans_id> (SurveyApiController.php:264,301) is not scoped by questionnaire_id, meaning a request carrying a valid qstr_url for questionnaire A but an ans_id belonging to questionnaire B's likerts/netpromoterscores row would still pass validation (exists:likerts,id only checks the row exists somewhere, not that it belongs to the given questionnaire) and would silently overwrite questionnaire B's answer. - No audit trail exists for any of these mutating calls (see §13) — a malicious or accidental delete/reset is undetectable after the fact from application logs alone.

GET /seed is a public, unauthenticated data-mutation endpoint

routes/web.php:10 registers Route::get('/seed', [AdminController::class,'seedFunction']) with no middleware. seedFunction() (AdminController.php:18-30) creates 4 hardcoded internal test participants (real consultancy staff email addresses) every time it is called, with no idempotency guard — repeated calls would create duplicate Questionnaire rows (the only uniqueness checks, on email/emp_id, live in addParticipantRequest, not in seedFunction/standardSurveyCreator itself). This route should never have been left reachable without at least auth middleware, and ideally should not exist as an HTTP route at all in a production deployment (a console/Artisan command would be the conventional, safer equivalent).

SQL injection risk via string interpolation (multiple sites)

Several raw SQL statements interpolate request-derived values directly into the query string rather than using parameter binding: - SurveyApiController::getUrlByIdentifier — identifier_value interpolated raw (:46); the identifier_type column-name side is closed off by validation (in:emp_id,email), but the value side is not bound. - AdminController::sendEmailRequest — $request->qids interpolated into an IN (...) clause (:243), mitigated only by an earlier regex validation ('regex:/^[0-9]+(,[0-9]+)*\$/', :234) rather than parameter binding. The regex is reasonably tight (digits and commas only), which substantially reduces — but does not structurally eliminate — the injection surface (a defense-in-depth concern rather than an actively exploitable hole given the regex). - AdminController::surveySummaryView/participantListView — et_id and demg (a column-name selector) are interpolated raw into WHERE/GROUP BY clauses (:98-100,136) with no validation at all beyond an implicit numeric-comparison usage pattern; these are reachable only by authenticated /admin/* session users, meaningfully lowering the risk, but a malicious or compromised staff session could still attempt injection via a crafted et_id/demg query string.

None of these use Eloquent's query builder or DB::select($sql, $bindings) parameter-binding form, which Laravel supports and which would close every one of these gaps with a one-line change per call site.

Missing server-side authorization on /admin/* role-gated actions

As noted in §9, Auth::user()->role_id == 1 gates the visibility of "Add Participant"/"Send Email" nav links in the Blade layout (resources/views/layouts/admin.blade.php:27) but the corresponding routes (GET/POST /admin/add-participant, GET/POST /admin/send-email) apply no equivalent check in AdminController — any authenticated user can call these directly regardless of role_id.

CORS is fully open (config/cors.php:18-32)

allowed_origins: ['*'], allowed_methods: ['*'], allowed_headers: ['*'] applies globally, including to /admin/*. As discussed in §12, supports_credentials: false limits the practical cross-origin session-hijack risk for the admin portal specifically, but this is an app-wide blanket policy, not scoped to just the intentionally-public /survey-api/* paths.

GET /mail/send is a broken route referencing a non-existent method

routes/web.php:11 → AdminController::standardEmailSender, which does not exist in AdminController.php. Not a security issue per se, but worth flagging here since a broken, unauthenticated route is at minimum an attack-surface/reconnaissance data point (a 500/BadMethodCallException response leaks stack-trace detail if APP_DEBUG=true in production).

CSRF is deliberately (and correctly, for this design) disabled only for /survey-api/*

bootstrap/app.php:15-17 scopes the CSRF exemption precisely to /survey-api/* — /admin/* and the login routes retain Laravel's default CSRF protection. This is the one piece of this security picture that is well-scoped and intentional, not an oversight.

app.zip committed to the repository root

A 47 MB build artifact at the repo root (see §1) is a source-control hygiene issue, not a direct vulnerability, but such artifacts can inadvertently ship secrets (.env files, credentials) baked into a prior deployment snapshot — its contents were not inspected as part of this pass and should be audited before considering this a non-issue.


16. Developer Guide

Prerequisites

  • PHP ^8.2 with the extensions Laravel 11 requires (mbstring, PDO with a MySQL or SQLite driver, OpenSSL, Tokenizer, XML, Ctype, JSON, BCMath).
  • Composer.
  • A MySQL-compatible database matching the externally-managed schema described in §8 (this codebase ships no migrations to create one from scratch — see the caveat below).

First-time setup

composer install
cp .env.example .env
php artisan key:generate

Then, manually add the environment variables this application actually needs beyond the stock .env.example contents (see §12 for the full reconstructed list) — at minimum:

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=<name of the externally-managed EES survey database>
DB_USERNAME=<...>
DB_PASSWORD=<...>

SENDGRID_API_KEY=<...>
TWILIO_SID=<...>
TWILIO_AUTH_TOKEN=<...>
TWILIO_WHATSAPP_FROM=<...>

CLIENT_PREFIX=<...>
CLIENT_NAME=<...>
CLIENT_COLOR_PRIMARY_HEX=<...>
CLIENT_COLOR_PRIMARY_RGB=<...>
CLIENT_COLOR_SECONDARY_HEX=<...>
CLIENT_COLOR_SECONDARY_RGB=<...>

Note: since there is no salt key declared in config/app.php at all, if you need standardSurveyCreator's token generation to be non-trivially guessable, you must both add a 'salt' => env('SALT') line to config/app.php and set a strong SALT value in .env — this is not merely an env-var omission, it requires a code change (see §9 and §15).

Database

This codebase has no migrations. To run it against a real database, you need either: 1. A dump/snapshot of the actual externally-managed EES survey schema (the tables in §8), obtained from whoever operates the production database, or 2. To hand-write migrations reconstructing the schema in §8 (marking which parts are Confirmed vs. Inferred, since the Inferred columns may be incomplete).

There is no database/seeders/DatabaseSeeder.php with real seed logic either — the closest equivalent is AdminController::seedFunction/standardSurveyCreator, which requires the questions table to already be populated with a specific, currently-undocumented set of question rows (ids 1, 2, 3, 36, 39, 41 are all referenced by hardcoded id in this codebase, per §6 and §8 — the actual content of the questions table is not present anywhere in this repository).

Running the app locally

php artisan serve

For WhatsApp bulk-send to actually process through the queue (once sendWhatsappRequest is fixed to use the queued path — see §10), also run:

php artisan queue:work

The composer.json "dev" script (composer.json:53-56) runs php artisan serve, queue:listen, pail (log tailing), and npm run dev concurrently — but there is no package.json/frontend build setup in this repository (no resources/js build pipeline beyond the stock Laravel Vite scaffold placeholder), so npm run dev in that composer script has nothing meaningful to build; the actual frontend SPA is a separate repository (EES-2026/EES-Survey-Portal) whose build output is manually copied into public/assets/ (see §2).

Testing

php artisan test

Runs the two stock placeholder tests only (tests/Feature/ExampleTest.php, tests/Unit/ExampleTest.php) — neither exercises any business logic in this codebase. There is currently no test coverage to rely on when making changes; manual testing via Postman/curl against /survey-api/* and the /admin/* Blade forms is the only verification method available as this codebase stands.

Common tasks

Task How
Add a new admin user No registration route exists (disabled, routes/web.php:8) — insert directly into the users table (with a bcrypt/argon2 password hash and, if role-gating matters, a role_id value) via php artisan tinker or direct DB access
Seed test participants GET /seed (currently public/unauthenticated — see §15) or call AdminController::standardSurveyCreator(...) directly via tinker
Manually reset a stuck respondent POST /survey-api/soft-reset-questionnaire or hard-reset-questionnaire with their qstr_url (no admin session needed, per §9)
Send a new WhatsApp campaign Edit the hardcoded $templateName/$due_date values in AdminController::sendWhatsappRequest (:321-322), and remove the hardcoded test-user override at :314-316 before deploying, or the endpoint will only message the one hardcoded test number

17. Code Quality Review

Findings below are drawn directly from the source. Severity is the author's engineering judgment. File:line citations point to the exact code discussed.

Correctness bugs (high priority)

# Location Issue
1 app/Http/Controllers/AdminController.php:299-316 (sendWhatsappRequest) The real pending/phone-only participant query (:299-312) is built and assigned to $users, then immediately overwritten by a hardcoded single-element test array (:314-316) before it is ever used. As currently written, this endpoint can never send WhatsApp messages to real participants — it always sends exactly one message to a hardcoded test number.
2 app/Http/Controllers/AdminController.php:335 (sendWhatsappRequest's catch block) return $e; returns a raw \Exception object as the controller's response value — Laravel cannot render this as an HTTP response, so this would itself raise a TypeError rather than gracefully reporting the original error. The response()->json(['formerror' => ...]) on the next line (:336) is unreachable dead code.
3 app/Services/SendGridMailService.php:25 catch (Exception $e) with no use Exception; import in the App\Services namespace resolves to the non-existent App\Services\Exception class, meaning this catch clause can never actually catch a real (global-namespace) \Exception thrown by the SendGrid SDK — it is dead code that will let real send failures propagate uncaught out of this method (they are, in practice, caught one level up by AdminController::sendEmailRequest's own \Exception-typed catch, so the end-to-end behavior is not broken, but this inner catch does not do what it appears to do).
4 routes/web.php:11 GET /mail/send routes to AdminController::standardEmailSender, a method that does not exist anywhere in AdminController.php. Calling this route raises a BadMethodCallException at runtime.
5 app/Http/Controllers/SurveyApiController.php:264,301 (submitAnswer) The UPDATE likerts/UPDATE netpromoterscores statements are scoped only by WHERE id = <ans_id>, with no accompanying AND questionnaire_id = <qstr->id> check — a request with a valid qstr_url but an ans_id belonging to a different questionnaire's answer row would still pass Laravel's exists:likerts,id validation (which only checks the row exists anywhere) and silently update the wrong questionnaire's data. See §15.
6 app/Http/Controllers/AdminController.php:20,297 (ini_set('max_execution_time', 600000)) 600,000 seconds (~166 hours) is almost certainly an extra-zero typo for 600 seconds — the surrounding code resets to 6000/600 afterward, suggesting the intended magnitude was hundreds, not hundreds-of-thousands, of seconds.
7 app/Http/Controllers/AdminController.php:21-22,196-197 Route file registers updateParticipantView/updateParticipantRequest (lowercase first letter, routes/web.php:21-22) while the controller declares UpdateParticipantView/UpdateParticipantRequest (capital U, AdminController.php:185,196). This works only because PHP method dispatch is case-insensitive; it is inconsistent with every other method name in the same file (all lowercase-first) and should be normalized.
8 app/Models/Questionnaire.php:12 (submitted_at in $fillable) No code path in this codebase ever writes to submitted_at — submitQuestionnaire sets completion via raw SQL (status = 'completed' only, SurveyApiController.php:223), bypassing Eloquent (and this fillable declaration) entirely. Either dead/vestigial, or a sign that survey-completion timestamping was intended but never finished.

Duplicate / dead code

  • suspendQuestionnaire is reachable from the public API but never invoked by any code in this backend — the trigger condition (an attention-check answer mismatch) must be entirely client-side logic not present in this repository, or this endpoint is purely an ops tool (see §7, §20).
  • App\Jobs\SendWhatsAppMessageJob and WhatsAppTemplateService::sendBulkTemplateMessages are both unused by any live controller code — AdminController::sendWhatsappRequest calls the synchronous sendTemplateMessage directly instead (see §10).
  • App\Http\Controllers\Auth\RegisterController, ResetPasswordController, ConfirmPasswordController, VerificationController are present (stock laravel/ui scaffolding) but their routes are never registered, since Auth::routes(['reset' => false, 'register' => false, 'verify'=>false]) (routes/web.php:8) disables exactly those route groups. These four controller files, and their corresponding Blade views (resources/views/auth/register.blade.php, passwords/*.blade.php, verify.blade.php), are dead weight in this codebase.
  • ProviderController-equivalent shared query layer does not exist here (unlike the sibling Report backend) — there is no duplication risk of that specific shape, but conversely there is also no shared abstraction at all; every raw SQL query is written independently per controller method with no reuse.
  • Commented-out started_at/answered_at/reviewed_at update fields (SurveyApiController.php:267-269,305-307) — vestigial code referencing columns that, per §8, are not confirmed to exist and are never actually written by any active code path.
  • Commented-out phone-uniqueness checks in both addParticipantRequest (AdminController.php:164-165) and UpdateParticipantRequest (:204-205) — left in place rather than removed, suggesting an intentionally-disabled validation rule rather than accidental dead code, but undocumented as to why.

Large classes / functions

  • SurveyApiController.php (444 lines total) is small in absolute terms but getQuestionnaire alone spans 137 lines (:63-199) with three separate inline raw-SQL query blocks and a hand-rolled bilingual follow-up-question data structure embedded directly in the controller — a strong candidate for extraction into a dedicated query/service class.
  • AdminController.php:294-338 (sendWhatsappRequest) mixes query construction, a hardcoded test override, loop-based message sending, and error handling all in one 45-line method.

Tight coupling / missing abstractions

  • No shared "resolve questionnaire by qstr_url or fail" helper — the identical Validator::make(['qstr_url' => 'required|string|exists:questionnaires,url']) block, followed by Questionnaire::where('url', $request->qstr_url)->first(), is copy-pasted verbatim across 8 of the 10 SurveyApiController methods (getQuestionnaire, submitQuestionnaire, submitAnswer, softResetQuestionnaire, hardResetQuestionnaire, deleteQuestionnaire, suspendQuestionnaire, selectLanguage). This is the single clearest refactoring opportunity in the file — see §18.
  • No shared JSON-envelope helper — the {"message": ..., "errors": ..., "res": ...} shape is hand-built inline in every method rather than via a small respond()/fail() helper on the base Controller class.
  • WhatsAppTemplateService reads env() directly in its constructor rather than via config/services.php (app/Services/WhatsAppTemplateService.php:14-15) — bypasses Laravel's config-caching (php artisan config:cache would freeze these to whatever they were at cache time, but since they're read via env() not config(), cached-config deployments could see stale/missing values depending on how env() resolves post-cache in the deployed PHP process).

Performance issues

Covered in detail in §14: no caching, no pagination on the admin participant list, Scale::with('scale_options')->get() loads the entire scales table on every questionnaire fetch, per-row (not batched) inserts in standardSurveyCreator.

Security concerns

Covered in detail in §15: fully unauthenticated /survey-api/*, undefined app.salt config key, unauthenticated /seed route, SQL-injection-shaped string interpolation in several raw queries, missing server-side role check on /admin/add-participant and /admin/send-email, fully open CORS.

Miscellaneous smells

  • Inconsistent HTTP status code conventions: validation failures return 400 throughout SurveyApiController (correct and consistent), but AdminController's Blade-form errors use redirect()->back()->with('formerror', ...) (a flash-message pattern appropriate for server-rendered forms) — the two controllers do not share any response convention, which is expected given they serve different client types (JSON API vs. server-rendered forms) but is worth noting for anyone expecting a single API style across the app.
  • Magic numbers throughout SurveyApiController/AdminController: question ids 1, 2, 3, 36, 39, 41 are hardcoded with business meaning (integrity question, dummy fillers, special-followup NPS question, gender-conditional questions) with no named constants — a future re-seed or schema change that renumbers questions would silently break multiple unrelated code paths.
  • Hardcoded outbound-email/WhatsApp content: the SendGrid From address (survey@engageconsulting.biz, SendGridMailService.php:12), the WhatsApp template SID and due-date string (AdminController.php:321-322), are all baked into code rather than configuration — every new survey wave requires a code change and redeploy to update these.
  • No named exception types — every catch clause in this codebase catches the generic \Exception (or, per finding #3 above, an incorrectly-scoped one), with no domain-specific exception hierarchy.

18. Improvement Opportunities

Refactoring

  1. Extract a findQuestionnaireOrFail($qstr_url) helper (or a FormRequest class with a route-model-binding-style resolver) to eliminate the 8x-duplicated validate-then-lookup pattern in SurveyApiController (see §17).
  2. Extract a uniform JSON response envelope helper ($this->success($message, $res) / $this->fail($message, $errors)) on the base Controller class, used consistently across SurveyApiController.
  3. Move getQuestionnaire's hardcoded NPS follow-up question definitions into the database (a question_followups table keyed by question_id, rather than a PHP conditional hardcoded to question id 36) — this removes the magic-id coupling between question content and controller code.
  4. Introduce Eloquent models for Likert, NetPromoterScore, IntegrityFeedback, and Question (currently only Questionnaire, Scale, ScaleOptions, Entity, User have models) with proper $fillable and relationships (Questionnaire::hasMany(Likert::class), etc.) — this would let the codebase replace most raw SQL with parameter-bound Eloquent queries, closing the SQL-injection-shaped gaps in §15 as a side effect.
  5. Fix sendWhatsappRequest to (a) remove the hardcoded test-user override, (b) dispatch via the already-written sendBulkTemplateMessages/SendWhatsAppMessageJob queued path instead of a synchronous loop, and (c) parameterize the template SID / due-date rather than hardcoding them.
  6. Add a role_id-based Gate/policy check (or middleware) to /admin/add-participant and /admin/send-email, matching the intent already expressed in the Blade nav-link gating (resources/views/layouts/admin.blade.php:27).

Security hardening

  1. Add real authentication to /survey-api/* mutating endpoints — at minimum, verify that a submitted ans_id belongs to the questionnaire identified by qstr_url before updating it (closing finding #5 in §17); consider a short-lived, per-session token issued by get-questionnaire and required on subsequent calls, rather than relying on the static invite URL as a durable credential for every operation.
  2. Define app.salt in config/app.php ('salt' => env('SURVEY_URL_SALT')) and ensure a strong, secret value is set in every deployment's .env — today the config key referenced by AdminController.php:45 does not exist in this file at all.
  3. Remove or auth-gate GET /seed — either delete the route entirely in favor of an Artisan console command, or wrap it in auth middleware plus a role check.
  4. Replace all string-interpolated raw SQL with parameter binding (DB::select($sql, $bindings) or Eloquent query builder methods) — see the specific sites cataloged in §15.
  5. Scope CORS more tightly if feasible — even though /survey-api/*'s tokenless design makes a fully-open CORS policy low-additional-risk for that surface specifically, scoping /admin/* out of the wildcard CORS policy would be a straightforward defense-in-depth improvement.

Maintainability

  1. Document the real environment variable list — replace the stock .env.example with one reflecting every variable actually read by this codebase (see the full reconstructed table in §12).
  2. Add real feature tests for SurveyApiController's 10 endpoints (currently zero coverage) — these are the highest-value, most business-critical code paths in the repository and are entirely untested.
  3. Remove dead code: the four unused Auth\* controllers/views (RegisterController, ResetPasswordController, ConfirmPasswordController, VerificationController), the unused SendWhatsAppMessageJob/sendBulkTemplateMessages pairing (or, preferably, wire it in rather than delete it — see refactoring item 5 above), the stray public/survey-main.blade.php (a different, stale client's build), and the 47MB app.zip build artifact.
  4. Fix the updateParticipantView/UpdateParticipantView method-name casing inconsistency to match the rest of the file's lowercase-first convention.
  5. Fix the max_execution_time typo (600000 → likely 600) in both AdminController::seedFunction and sendWhatsappRequest.

Architecture

  1. Introduce a thin repository/service layer for questionnaire data access, mirroring what the sibling "EES - Report backend" has in ProviderController (even accounting for that codebase's own duplication issues, a single shared query layer would still be strictly better than the current zero-abstraction raw-SQL-per-method pattern in this backend).
  2. Consider whether /survey-api/*'s six ops-only endpoints (get-style-metadata, get-url-by-identifier, soft-reset-questionnaire, hard-reset-questionnaire, delete-questionnaire, suspend-questionnaire) belong on the public API surface at all, given the frontend never calls them (see §7) — moving them behind the existing /admin/* session-auth group (as staff-facing actions triggered from a UI, rather than raw POST endpoints callable by anyone) would close the largest security gap in this document without removing any functionality staff actually need.
  3. Formalize the multi-client/white-label build process — right now, which client's SPA build is "live" is determined entirely by which files happen to be present in public/assets/ and which Blade view SurveyController::surveyMainView points at; a documented, scripted per-client deploy process (rather than manual asset-folder swaps) would reduce the risk of another stale public/survey-main.blade.php-style leftover.

19. System Diagrams

Component map

flowchart LR
    subgraph "Route layer"
        WEB["routes/web.php - single file, 40 lines"]
    end
    subgraph "Public survey-taking surface"
        SAPI["SurveyApiController - 10 methods, no auth"]
    end
    subgraph "Admin surface"
        ADM["AdminController - session-authenticated"]
        AUTHC["Auth/* - LoginController live, - Register/Reset/Confirm/Verify dead"]
    end
    subgraph "SPA shell"
        SCTRL["SurveyController"]
    end
    subgraph "Models"
        QMODEL["Questionnaire"]
        EMODEL["Entity"]
        SCMODEL["Scale / ScaleOptions"]
        UMODEL["User"]
    end
    subgraph "Services"
        SG["SendGridMailService"]
        WA["WhatsAppTemplateService"]
    end
    subgraph "Jobs"
        JOB["SendWhatsAppMessageJob - unused by live code"]
    end
    subgraph "External"
        SGAPI[["SendGrid"]]
        TWAPI[["Twilio"]]
    end
    DB[("Externally-managed DB - no migrations in this repo")]

    WEB --> SAPI & ADM & SCTRL & AUTHC
    SAPI --> QMODEL & SCMODEL
    SAPI -->|"raw SQL"| DB
    ADM --> QMODEL & EMODEL
    ADM -->|"raw SQL"| DB
    ADM --> SG --> SGAPI
    ADM --> WA --> TWAPI
    WA -.-> JOB
    AUTHC --> UMODEL
    QMODEL & EMODEL & SCMODEL & UMODEL --> DB

Product-suite relationship (as evidenced by this codebase)

This backend, the React SPA at EES-2026/EES-Survey-Portal, and the sibling "EES - Report backend" together plausibly form one product suite for a single client engagement, though the direct evidence for this relationship found in this repository is limited to shared naming/branding conventions, not any code-level integration:

flowchart TB
    subgraph "Survey-taking product - this doc + frontend doc"
        SPA["EES-Survey-Portal - React SPA"]
        SPBackend["Ees-survey-backend - THIS REPOSITORY"]
        SPA -->|"/survey-api/star, no auth"| SPBackend
    end
    subgraph "Reporting/analytics product - separately documented"
        RPBackend["EES - Report backend - separate Laravel app"]
        RPFrontend["EES-V2.0-Frontend - not in this checkout"]
        RPFrontend -->|"/api/star, Sanctum"| RPBackend
    end
    SPBackend -.->|"writes questionnaires, likerts, netpromoterscores, etc. - NO code-level link confirmed"| SharedDB[("Shared or separate database? - NOT CONFIRMED")]
    RPBackend -.->|"presumably reads survey response data for reporting - NO code-level link confirmed"| SharedDB

This diagram's dashed relationship to a shared database is speculative, not confirmed. Nothing in this repository references the "EES - Report backend" codebase, and nothing in that codebase (per its own documentation) references this one. The natural product hypothesis — that this backend collects raw survey responses (entries-equivalent: questionnaires/likerts/netpromoterscores) which the Report backend later reads/aggregates for the client-facing analytics dashboards — is plausible given the shared domain (EES, employee engagement surveys) and consultancy branding, but the Report backend's own documentation explicitly states it found no ingestion pipeline in its own checkout and assumed one exists "but will be added later." This document cannot confirm or deny that this repository is that missing ingestion source; the two codebases' table names do not obviously overlap in the fragments read (this backend's likerts/netpromoterscores/questionnaires vs. the Report backend's documented entries/scores/netpromoterscores — note netpromoterscores is a shared table name between the two, which is suggestive but not conclusive of a shared database). See §20.


20. Appendix — Assumptions & "Unable to determine" items

From this backend's own analysis

  • app.salt's real production value — not determinable from this repository. It is possible a production deployment defines an additional config merge or .env-sourced salt key not reflected in the checked-in config/app.php; this document can only confirm that, as committed, no such key exists in the file. (Flagged as a probable but not certain security gap — see §9/§15.)
  • The six /survey-api/* endpoints the frontend never calls (get-style-metadata, get-url-by-identifier, soft-reset-questionnaire, hard-reset-questionnaire, delete-questionnaire, suspend-questionnaire) — their real-world calling pattern (staff via Postman/a script, a different unseen internal tool, or simply unused/aspirational) is not confirmable from either this backend or the frontend codebase. (Assumption: most likely ops tooling, per §7.)
  • What triggers suspend-questionnaire in practice — the hardcoded suspend object in getQuestionnaire's response strongly implies client-side attention-check logic that would call this endpoint on a specific wrong-answer condition, but no such logic is described in the frontend documentation's account of questionsService.js's four called endpoints (which does not include suspend-questionnaire). Unable to determine whether this feature is implemented client-side in a part of the frontend not covered by its documentation, implemented in a different/newer frontend build not covered by either doc, or simply unused/aspirational.
  • The questions table's actual content (which ids exist, their full text, which scale each uses) — never dumped or migrated in this repository; only inferable indirectly from the magic ids (1, 2, 3, 36, 39, 41) referenced in code.
  • How the first/any /admin user account is provisioned — registration is disabled (routes/web.php:8) and no seeder creates a User row. Unable to determine from the codebase — presumably a manual DB insert or tinker session performed once outside version control.
  • users.role_id's full value vocabulary — only role_id == 1 is ever checked (resources/views/layouts/admin.blade.php:27); what other values mean, whether the column has a default, and whether it is even declared NOT NULL are unknown.
  • Whether MySQL, MariaDB, PostgreSQL, or another RDBMS is used in production — config/database.php defaults to sqlite, but the raw SQL throughout SurveyApiController.php/AdminController.php uses MySQL-idiomatic syntax (backtick-free identifier quoting, SELECT ... AS 'alias' with single-quoted aliases, which is MySQL-permissive but not standard-SQL) — Assumption: production runs MySQL or MariaDB, consistent with the sibling Report backend's confirmed MySQL usage, but not directly confirmed for this specific repository.
  • config/logging.php and config/broadcasting.php were not enumerated in this pass (not listed among the files the task specified) — logging behavior is inferred only from .env.example's LOG_CHANNEL=stack/LOG_STACK=single defaults.
  • app.zip's contents — not opened or analyzed; flagged only as a likely-accidental committed build artifact.

Reconciling against the frontend documentation's own "unable to determine" list

The frontend documentation (docs/survey-portal-frontend-documentation.md) flagged several items as unresolved from the frontend side alone. Having now read this backend in full, here is what is resolved and what remains open:

Frontend doc's open question Resolved by this backend? Resolution
Why does axiosInstance.js read a bearer token from localStorage that nothing ever writes? Yes, resolved. This backend's /survey-api/* routes check no Authorization header, no Sanctum guard, nothing — see §9. The dead interceptor code is consistent with (and fully explained by) a backend that never expected a token on this surface.
What is the provenance of public/survey-main.blade.php (the Martin Dow build snapshot found in the frontend repo)? Yes, resolved. This exact same pattern — a stale, different-client SPA-build Blade shell committed alongside the live build — exists in this backend's own public/ folder (public/survey-main.blade.php, titled "Martin Dow Employee Engagement Survey 2026", referencing hashed assets not present in this checkout's public/assets/). The live build actually served is resources/views/frontend/survey-main.blade.php (Dubai Islamic Bank), matching public/index.html/public/assets/* exactly. See §2, §4.3.
What is the origin/owner of public/branding.json? Yes, resolved. It is served from this Laravel backend's own public/ directory (d:/Downloads/EES Documentation/EES-2026/Ees-survey-backend/public/branding.json), confirming the frontend and this backend's static files are deployed to (or proxied through) the same document root in production, not two independently-hosted origins. See §2.
Full backend contract for the four called endpoints (validation rules, auth requirements, response schemas) Yes, resolved. Full method-by-method documentation in §4.1, §6, §7.
Whether the backend validates qstr_url server-side, and what "invalid" looks like Yes, resolved. Every endpoint validates qstr_url via required|string|exists:questionnaires,url and returns a structured 400 on failure — see §7's response-shape column.
Backend-side token expiry/validity window for the survey link Still open. No expiry column or check exists anywhere in this codebase's schema-usage or controller logic — questionnaire URLs appear to remain valid indefinitely (until a delete-questionnaire call), but this cannot be fully ruled out as enforced at an infrastructure layer (e.g., a reverse proxy or WAF rule) outside this repository.

Document generated from static analysis of the repository at d:/Downloads/EES Documentation/EES-2026/Ees-survey-backend. Line-number references reflect the state of the code at analysis time and may drift as the code evolves.