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, withpath/to/file.php:LINEcitations.Scope note. This is the backend that the separately-documented React frontend at
EES-2026/EES-Survey-Portalcalls under/survey-api/*(seedocs/survey-portal-frontend-documentation.md). This backend is not the "EES Report backend" (EES-2026/EES - Report backend, documented separately atdocs/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¶
- Backend Overview
- Project Structure
- Architecture
- Module / Controller Breakdown
- Request Lifecycle
- Business Logic
- API Layer
- Database Interaction
- Authentication & Authorization
- Background Processing
- Integrations
- Configuration
- Logging & Error Handling
- Performance Considerations
- Security
- Developer Guide
- Code Quality Review
- Improvement Opportunities
- System Diagrams
- 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:
- Serve the public, unauthenticated
/survey-api/*JSON endpoints that the React SPA atEES-2026/EES-Survey-Portalcalls 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). - 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) |
sendgrid/sendgrid |
App\Services\SendGridMailService::sendBulkEmail — bulk personalized invite email |
|
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 viaDB::select/DB::update/DB::delete, and return aresponse()->json([...])envelope withmessageandreskeys. 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 rawDB::select/DB::table()->insert()calls. See §4.2.app/Http/Controllers/SurveyController.php— a 10-line controller with a single method,surveyMainView(), that returnsview('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 inpublic/assets/— it does not render any dynamic PHP content beyond the view() call.database/— contains only a.gitignorefile. There is nomigrations/,seeders/, orfactories/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 acrossSurveyApiController.phpandAdminController.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, matchingresources/views/frontend/survey-main.blade.php:7-9exactly) and a leftover Martin Dow build shell (public/survey-main.blade.php:7-9, referencingindex-C7EXaJYr.js/index-DLBgWkL9.css— those hashed files are not present inpublic/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'sStart.jsxfetches 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'spublic/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'spublic/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/— bothFeature/ExampleTest.phpandUnit/ExampleTest.phpare the stock Laravel placeholders, unmodified (tests/Feature/ExampleTest.php:8-18just assertsGET /returns 200;tests/Unit/ExampleTest.phpassertstrue === 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
- Fat-controller, no service layer for business logic. There is no repository layer, no dedicated query-builder/DTO classes, and no
FormRequestclasses at all (every validation call is an inlineValidator::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. - "Service" classes exist, but are narrowly scoped to external integrations, not general business orchestration:
App\Services\SendGridMailService(SendGrid wrapper,sendBulkEmailis astaticmethod) andApp\Services\WhatsAppTemplateService(Twilio wrapper, instantiated via constructor injection inAdminController::sendWhatsappRequest(WhatsAppTemplateService $whatsapp),app/Http/Controllers/AdminController.php:294). - 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.Questionnaireis 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(inAdminController::addParticipantView), and all ofSurveyApiController'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). - 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 asguest(bootstrap/app.php:19). There are no Laravel Events/Listeners, no Jobs beyond the one WhatsApp job, and no Observers. - Response conventions are inconsistent between the two controllers, but self-consistent within
SurveyApiController. EverySurveyApiControllermethod 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, mixesredirect()->back()->with(...)(flash-message pattern for the Blade forms),view(...)(page renders), and one rawresponse()->json([...])(insendWhatsappRequest) — there is no single response contract across the app. - No API versioning, no OpenAPI/Swagger documentation, no rate limiting configured anywhere in this codebase (no
throttle:middleware is applied to any route inroutes/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 forlogo_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 fromconfig/app.php'sclient_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) — validatesidentifier_type(emp_idoremail) andidentifier_value, then runsSELECT 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. Becauseidentifier_typeis constrained by the validator to exactlyemp_idoremail(an enum viain:emp_id,email,:36), the column-name injection vector is closed by validation, butidentifier_valueis 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) — validatesqstr_urlexists inquestionnaires.url, loads theQuestionnairerow, then, only if$qstr->status == 'pending'(:87), runs three separate raw SQL queries joininglikerts/netpromoterscorestoquestionsto 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 allScalerows with theirscale_optionseager-loaded (Scale::with('scale_options')->get(),:187). If the questionnaire is notpending(i.e.completed,suspended, orclosed),questionsandscalesare returned as empty collections — the frontend is expected to interpret this state fromqstr.status(see the frontend'sStatusRouteguard, which redirects onstatus === "completed"). This is the single largest and most business-logic-dense method in the controller — see §6.1.submitAnswer(Request $request)(:234-325) — validatesqstr_urlandans.type(lkrtornps), then branches into aswitchwith per-type validation rules applied a second time: forlkrt, requiresans.score(1-5 integer),ans.ans_state(yes/no),ans.ans_id(mustexist:likerts,id), and conditionallyans.other_text(required_if:ans.qst_id,1— i.e. only mandatory for question id 1, the integrity question); fornps, requiresans.score(0-10 integer),ans.other_text(required but nullable — an unusual combination, see §17),ans.ans_state, andans.ans_id(mustexist:netpromoterscores,id). On success it runs a singleUPDATEagainst the matching table (likertsornetpromoterscores) keyed byans_id, and forlkrtanswers to question id 1 specifically, additionallyupdateOrInserts a row inintegrity_feedbackskeyed bylikert_id(:271-276). Noquestionnaire_idscoping is applied to theUPDATE— the update targetslikerts/netpromoterscorespurelyWHERE id = <ans_id>(:264,301), relying entirely onans_idhaving been validated toexist:likerts,id/exist:netpromoterscores,id, with no check that the givenans_idactually belongs to the questionnaire identified byqstr_url— see §15 for the cross-questionnaire-answer-tampering implication.submitQuestionnaire(Request $request)(:201-232) — validatesqstr_url, then checks whether anylikertsornetpromoterscoresrow for that questionnaire has aNULLscoreorNULLans_state(:214-215); if so, returns a 400 "Response to all questions have not been submitted yet." Otherwise setsquestionnaires.status = 'completed'(:223). This is the sole business rule gating survey completion — see §6.2.softResetQuestionnaire(Request $request)(:327-347) — setsquestionnaires.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 accidentalsubmitQuestionnairecompletion, or reopen asuspendedsurvey).hardResetQuestionnaire(Request $request)(:349-372) — setsstatus = 'pending'andlang = NULL, then nulls outscore/ans_stateon everylikertsrow andscore/other_text/ans_stateon everynetpromoterscoresrow for that questionnaire, and deletes the associatedintegrity_feedbacksrow. This fully rewinds a questionnaire to its pristine, never-started state while keeping the samelikerts/netpromoterscoresrows (and thus the sameans_ids) — it does not re-shuffle or regenerate the question set.deleteQuestionnaire(Request $request)(:374-397) — hard-deletes thequestionnairesrow itself, plus itsintegrity_feedbacks,likerts, andnetpromoterscoresrows. 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: thelikerts/netpromoterscorescleanup statements are dispatched viaDB::update(...)(:388-389) even though their SQL text isDELETE FROM ...— Laravel'sDB::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) — setsstatus = 'suspended'only. This is the mechanism referenced by thesuspendobject hardcoded intogetQuestionnaire'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 callssuspendQuestionnaireautomatically — 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 describequestionsServicecalling this endpoint — see §20).selectLanguage(Request $request)(:421-443) — validateslangisengorurdu, setsquestionnaires.langaccordingly. Matches the frontend'sLanguage.jsxcall 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 publicGET /seedroute (routes/web.php:10) with no middleware at all, not even inside theauthgroup. It bumpsmax_execution_timeto 600000 seconds and callsstandardSurveyCreatorwith 4 hardcoded internal test participants (nauman@engageconsulting.bizetc.,: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 bothseedFunctionandaddParticipantRequest. For each participant: creates aQuestionnairerow with a deterministic invite tokensha1(Config::get('app.salt') . '___' . $o['emp_id'])(:45— see §9 and §15 for why this is significant:app.saltis not defined anywhere inconfig/app.phpin this codebase, see §12); normalizes email (strips non-breaking spaces and whitespace, lowercases) and name (title-cases); setsstatus: 'pending',lang: null. It then randomly assigns a question set: selects allquestionswithid > 3(excluding ids 39 and 41) oftype = '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 onelikertsrow per selected question (questionnaire_id,question_idonly —score/ans_statedefault toNULLat the DB level, populated later viasubmitAnswer). It conditionally adds one gender-specific Likert question: question id 41 ifdemg1 == 'Female', id 39 ifdemg1 == 'Male'(:67-75) — this is the only placeid 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 onenetpromoterscoresrow pertype = 'nps'question (unconditionally, no shuffling). Finally it inserts one morelikertsrow for question id 1 unconditionally (:83-85) — this is the "integrity" question referenced throughoutSurveyApiController(see §6).redirector()(:90-93) —GET /adminredirects to/admin/survey-summary.surveySummaryView(Request $request)(:95-131) — builds a completion-status pivot (pending/completed/closed/totalcounts) grouped by one ofdemg1/demg2/demg3(selectable via?demg=), optionally filtered by?et_id=(entity id), via two rawSELECT ... GROUP BYqueries string-interpolating both thedemgcolumn name and theet_idfilter directly into the SQL (:98-122— see §15, thoughet_idanddemghere come from a same-origin admin form, not arbitrary public input). Rendersadmin.survey-summary.participantListView(Request $request)(:133-145) — lists allquestionnairesjoined toentities, optionally filtered byet_id. Rendersadmin.participant-list.addParticipantView()/addParticipantRequest(Request $request)(:147-183) — a manual single-participant add form; validates email format and uniqueness of email/emp_idagainst existingquestionnairesrows (phone-uniqueness check is present but commented out,:164-165), then delegates tostandardSurveyCreatorwith a single-element array.UpdateParticipantView($id, Request $request)/UpdateParticipantRequest($id, Request $request)(:185-221) — edits an existingQuestionnairerow'semp_id/name/email/entity_id/demg1-3(does not regenerate theurltoken or touchlikerts/netpromoterscores, so editing a participant after their questionnaire has been seeded does not re-randomize their question set). Note: the route inroutes/web.php:21-22references lowercase-firstupdateParticipantView/updateParticipantRequest, while the controller methods are defined asUpdateParticipantView/UpdateParticipantRequest(:185,196— capitalU). 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. Validatesemail_subject/email_bodyare required and an optionalqidsparam matches a comma-separated-integers regex (:234); queries allpendingquestionnaires with a non-null email (optionally restricted to specificqidsifspecidsis present in the request,:242-244— note the SQL-injection-shaped string concatenation of$request->qidsdirectly into theIN (...)clause at:243, mitigated only by the earlier regex validation, not parameter binding — see §15); builds one SendGridPersonalizationobject per recipient with%name%/%url%substitution tokens, and callsSendGridMailService::sendBulkEmail(...).sendWhatsappRequest(WhatsAppTemplateService $whatsapp)(:294-338) — intended to bulk-send WhatsApp invites to allpendingquestionnaires 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 realpending-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 queuedsendBulkTemplateMessages/SendWhatsAppMessageJobpath that exists in the codebase — see §10.standardEmailSender— referenced byroutes/web.php:11(GET /mail/send→AdminController::standardEmailSender) but no method with this name exists inAdminController.phpas read in this pass. Calling this route would raise a LaravelBadMethodCallExceptionat 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-generatedAuth\LoginController(app/Http/Controllers/Auth/LoginController.php), using Laravel's defaultwebguard (config/auth.php:39-42, session-driver,App\Models\UserEloquent provider). routes/web.php:15-26wraps every/admin/*route inRoute::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:27checksAuth::user()->role_id == 1to conditionally show "Add Participant" and "Send Email" nav links. This is presentation-layer gating only — the underlying routesGET/POST /admin/add-participantandGET/POST /admin/send-emailhave no server-side role check inAdminControlleritself; any authenticated user (regardless ofrole_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:
app.saltis referenced but never defined. A full search ofconfig/app.php(and every other config file in this codebase) finds no'salt' => ...key declared anywhere —config/app.phpdefinesclient_prefix,client_name,client_clr_prm_rgb/hex,client_clr_sec_rgb/hex(all reading their own like-named env vars) but nosaltkey at all (config/app.php:5-10).Config::get('app.salt')will therefore returnnullunless an.envvalue is separately wired into asaltkey that does not exist in the checked-inconfig/app.php— meaning, as this codebase stands, the token effectively reduces tosha1('___' . $emp_id)if no undocumented config addition exists in the deployed environment. Sinceemp_idvalues observed in the seed data are simple, low-entropy strings (e.g.'int0001',AdminController.php:23), a token derived from a null-saltsha1of a predictableemp_idpattern 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.- Even with a strong salt, the token is deterministic per
emp_id, not randomly generated per questionnaire instance — the sameemp_idwill always produce the sameurlfor 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 standardDispatchable, InteractsWithQueue, Queueable, SerializesModelstraits (: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 resolvesWhatsAppTemplateServicevia the container at execution time and callssendTemplateMessage($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 callsSendWhatsAppMessageJob::dispatch($number, $templateName, $params)for each. - However,
sendBulkTemplateMessagesis 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 aforeachloop (: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 —SendWhatsAppMessageJoband 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 synchronousforeachblocking the HTTP request/response cycle, and avoiding the risk of a Twilio rate-limit or timeout mid-loop) should switchsendWhatsappRequestto callsendBulkTemplateMessagesinstead.
Queue configuration (config/queue.php)¶
- Default connection:
env('QUEUE_CONNECTION', 'database')(config/queue.php:16) — the.env.examplein this repo also setsQUEUE_CONNECTION=databaseexplicitly. This means, by default, queued jobs are persisted to ajobsdatabase table (config/queue.php:37-44, table name configurable viaDB_QUEUE_TABLE, default'jobs') and require a runningphp artisan queue:work(orqueue:listen) process to actually execute — there is no evidence in this repository of such a worker process being configured for deployment (noProcfile, nosupervisorconfig, 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 afailed_jobstable by default. - No Laravel Horizon, no custom queue worker process management, and no scheduled jobs (
app/Console/Kernel.phpdoes not exist in this Laravel 11 skeleton style — scheduling, if any, would be registered viaroutes/console.php, which was not found to contain anySchedule::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) — astaticmethod, instantiated withnew \SendGrid($sendgridApiKey)where$sendgridApiKey = \Config::get('services.sendgrid_api_key')(:11, sourced fromconfig/services.php:17→env('SENDGRID_API_KEY')). - Behavior: chunks the incoming
Personalizationarray into batches of 1000 (array_chunk(..., 1000, true),:10— SendGrid's per-request personalization limit), builds oneSendGridMailobject per chunk with a fixedFromaddress (survey@engageconsulting.biz, "Surveyor",:12-13), HTML content, and sends each chunk via$sendgrid->send($email). On a caughtException(note: thecatch (Exception $e)clause at:25catches the global\Exceptiononly if this file'suseimports resolve it correctly — nouse Exception;import is declared at the top of this file, socatch (Exception $e)actually referencesApp\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 onePersonalizationper pending, emailed participant with%name%/%url%substitution tags, and passes the admin-suppliedemail_subject/email_bodyfrom 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 aTwilio\Rest\Clientusingenv('TWILIO_SID')/env('TWILIO_AUTH_TOKEN')read directly via theenv()helper in the service constructor, not via aconfig/services.phpentry (:14— this bypasses Laravel's config-caching best practice;env()calls outsideconfig/*.phpfiles will not reflect cached config in production ifconfig:cacheis 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(...), returningtrue/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_datestring ('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,
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-23has an emptywithExceptions(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 ifAPP_DEBUG=true, or a generic error page otherwise). - Validation errors are handled consistently within
SurveyApiController— every method wraps its logic in an explicitValidator::make(...)->fails()check and returns a hand-built{"message": ..., "errors": [...], "res": []}400 response rather than letting Laravel's automaticValidationExceptionJSON response fire. This is a deliberate, consistent pattern across all 9 validated methods in that controller. AdminController::sendWhatsappRequesthas a broken error-handling path. Itscatch (\Exception $e)block (app/Http/Controllers/AdminController.php:331-337) callsLog::info($e)(notLog::error— logging an exception object atinfoseverity, which most log-level filters would not surface as urgently as an error) and thenreturn $e;(:335) — returning the raw\Exceptionobject directly as the controller's return value. Laravel's routing layer cannot render anExceptionobject as an HTTP response, so this would itself throw aTypeErrorat the framework level rather than returning any kind of graceful error response to the client. Thereturn response()->json(['formerror' => ...])line immediately after (:336) is unreachable dead code — it can never execute because thereturn $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 correspondinguse Exception;import in this file's namespace (App\Services), so the caught type resolves to the non-existentApp\Services\Exception— meaning a real SendGrid API exception (which would be\Exceptionor a subclass in the global namespace) would not be caught by this clause at all, and would instead propagate uncaught up throughAdminController::sendEmailRequest's own outertry { ... } catch (\Exception $e)block (AdminController.php:239,289), which correctly imports the global\Exceptionimplicitly (nouseneeded, since it's referenced with the full\Exceptionleading-backslash syntax) — so in practice the outer catch inAdminControllerstill handles it gracefully, but the inner catch inSendGridMailServiceis dead code.- No structured/centralized logging.
\Log::error(...)is used once, inWhatsAppTemplateService::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.phpwas not present among the files enumerated for this pass (the standard LaravelLOG_CHANNEL=stack/LOG_STACK=singlevalues from.env.exampleimply the defaultstorage/logs/laravel.logsingle-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) — everygetQuestionnairecall re-runs three raw SQL queries plus aScale::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::participantListViewloads all matchingquestionnairesrows (optionally filtered byentity_id) in a single query with noLIMIT/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 aLEFT 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 multipleDB::table(...)->insert()calls per question rather than a single batchedinsert([...])call with all rows at once (AdminController.php:61-65,77-81insert one row per loop iteration, not a single multi-row insert) — for a bulk import of many participants viaaddParticipantRequest/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 be600seconds given the comment pattern and the later reset to6000/600in the same methods) — this looks like a typo (an extra000) 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, everyget-questionnaire/submit-answer/etc. call would perform a full table scan, which would degrade significantly as thequestionnairestable 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.2with 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¶
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¶
For WhatsApp bulk-send to actually process through the queue (once sendWhatsappRequest is fixed to use the queued path — see §10), also run:
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¶
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¶
suspendQuestionnaireis 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\SendWhatsAppMessageJobandWhatsAppTemplateService::sendBulkTemplateMessagesare both unused by any live controller code —AdminController::sendWhatsappRequestcalls the synchronoussendTemplateMessagedirectly instead (see §10).App\Http\Controllers\Auth\RegisterController,ResetPasswordController,ConfirmPasswordController,VerificationControllerare present (stocklaravel/uiscaffolding) but their routes are never registered, sinceAuth::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_atupdate 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) andUpdateParticipantRequest(: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 butgetQuestionnairealone 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_urlor fail" helper — the identicalValidator::make(['qstr_url' => 'required|string|exists:questionnaires,url'])block, followed byQuestionnaire::where('url', $request->qstr_url)->first(), is copy-pasted verbatim across 8 of the 10SurveyApiControllermethods (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 smallrespond()/fail()helper on the baseControllerclass. WhatsAppTemplateServicereadsenv()directly in its constructor rather than viaconfig/services.php(app/Services/WhatsAppTemplateService.php:14-15) — bypasses Laravel's config-caching (php artisan config:cachewould freeze these to whatever they were at cache time, but since they're read viaenv()notconfig(), cached-config deployments could see stale/missing values depending on howenv()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
400throughoutSurveyApiController(correct and consistent), butAdminController's Blade-form errors useredirect()->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 ids1,2,3,36,39,41are 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
Fromaddress (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
catchclause 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¶
- Extract a
findQuestionnaireOrFail($qstr_url)helper (or aFormRequestclass with a route-model-binding-style resolver) to eliminate the 8x-duplicated validate-then-lookup pattern inSurveyApiController(see §17). - Extract a uniform JSON response envelope helper (
$this->success($message, $res)/$this->fail($message, $errors)) on the baseControllerclass, used consistently acrossSurveyApiController. - Move
getQuestionnaire's hardcoded NPS follow-up question definitions into the database (aquestion_followupstable keyed byquestion_id, rather than a PHP conditional hardcoded to question id36) — this removes the magic-id coupling between question content and controller code. - Introduce Eloquent models for
Likert,NetPromoterScore,IntegrityFeedback, andQuestion(currently onlyQuestionnaire,Scale,ScaleOptions,Entity,Userhave models) with proper$fillableand 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. - Fix
sendWhatsappRequestto (a) remove the hardcoded test-user override, (b) dispatch via the already-writtensendBulkTemplateMessages/SendWhatsAppMessageJobqueued path instead of a synchronous loop, and (c) parameterize the template SID / due-date rather than hardcoding them. - Add a
role_id-basedGate/policy check (or middleware) to/admin/add-participantand/admin/send-email, matching the intent already expressed in the Blade nav-link gating (resources/views/layouts/admin.blade.php:27).
Security hardening¶
- Add real authentication to
/survey-api/*mutating endpoints — at minimum, verify that a submittedans_idbelongs to the questionnaire identified byqstr_urlbefore updating it (closing finding #5 in §17); consider a short-lived, per-session token issued byget-questionnaireand required on subsequent calls, rather than relying on the static invite URL as a durable credential for every operation. - Define
app.saltinconfig/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 byAdminController.php:45does not exist in this file at all. - Remove or auth-gate
GET /seed— either delete the route entirely in favor of an Artisan console command, or wrap it inauthmiddleware plus a role check. - 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. - 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¶
- Document the real environment variable list — replace the stock
.env.examplewith one reflecting every variable actually read by this codebase (see the full reconstructed table in §12). - 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. - Remove dead code: the four unused
Auth\*controllers/views (RegisterController,ResetPasswordController,ConfirmPasswordController,VerificationController), the unusedSendWhatsAppMessageJob/sendBulkTemplateMessagespairing (or, preferably, wire it in rather than delete it — see refactoring item 5 above), the straypublic/survey-main.blade.php(a different, stale client's build), and the 47MBapp.zipbuild artifact. - Fix the
updateParticipantView/UpdateParticipantViewmethod-name casing inconsistency to match the rest of the file's lowercase-first convention. - Fix the
max_execution_timetypo (600000→ likely600) in bothAdminController::seedFunctionandsendWhatsappRequest.
Architecture¶
- 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). - 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. - 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 viewSurveyController::surveyMainViewpoints at; a documented, scripted per-client deploy process (rather than manual asset-folder swaps) would reduce the risk of another stalepublic/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-sourcedsaltkey not reflected in the checked-inconfig/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-questionnairein practice — the hardcodedsuspendobject ingetQuestionnaire'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 ofquestionsService.js's four called endpoints (which does not includesuspend-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
questionstable'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
/adminuser account is provisioned — registration is disabled (routes/web.php:8) and no seeder creates aUserrow. Unable to determine from the codebase — presumably a manual DB insert ortinkersession performed once outside version control. users.role_id's full value vocabulary — onlyrole_id == 1is ever checked (resources/views/layouts/admin.blade.php:27); what other values mean, whether the column has a default, and whether it is even declaredNOT NULLare unknown.- Whether MySQL, MariaDB, PostgreSQL, or another RDBMS is used in production —
config/database.phpdefaults tosqlite, but the raw SQL throughoutSurveyApiController.php/AdminController.phpuses 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.phpandconfig/broadcasting.phpwere not enumerated in this pass (not listed among the files the task specified) — logging behavior is inferred only from.env.example'sLOG_CHANNEL=stack/LOG_STACK=singledefaults.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.