Arsi India Info
Portfolio Build · Public GitHub Demo

React + PHP SaaS CRM

A small-business CRM — companies, contacts, a lead pipeline with real conversion logic, a deal pipeline board, tasks, an activity timeline, dashboards and reports — built with React, PHP CodeIgniter 4 and MySQL, anchored by one reusable server-side list API every module shares.

Repository
react-php-saas-crm
Author
R.M. — Arsi India Info
Category
Full-Stack · PHP · React · MySQL
License
MIT (code) + Trademark Notice (brand)

1. Executive Summary

Ownership This project, including its source code, documentation and this plan, is authored and published by Arsi India Info. It is released publicly under the MIT License for portfolio and learning purposes; the Arsi India Info name and logo remain protected brand assets.

The platform lets a small sales team — modeled here as the fictional Brightfield Business Solutions — track companies and contacts, run inbound leads through a real qualification-to-conversion workflow, manage open deals on a pipeline board, follow up with tasks, and log every call/email/meeting/note against the record it belongs to. The project's centerpiece is not any single screen but a single, reusable, server-side list contract (search + filter + sort + pagination) that every list-style module — Companies, Contacts, Leads, Deals, Tasks — implements identically, plus ownership-based row visibility so a sales rep only ever sees their own pipeline while a manager sees their team's.

10
MySQL Tables
12
Stored Procedures
36
API Endpoints
11
Screens
1
Shared List API

Scope is deliberately narrow where breadth would add no portfolio value: one currency, no custom-field builder, no email/telephony integration, no quotes/invoicing, no marketing automation. Companies and Contacts are the only "account" records — see §2 for why this plan consolidates what the source brief calls "Customers" and "Companies" into one table rather than two overlapping ones. All data is synthetic (fictional company, fake leads and deals) — no real business relationships.

2. Why This Is a Top-Tier Portfolio Project

CRM CRUD demos are common; a CRM that gets lead conversion and list-endpoint reuse right is not. This project is chosen because it forces decisions a reviewer will recognize as production-grade full-stack engineering:

Depth it proves

  • A transactional, multi-table "convert lead → company + contact + deal" operation implemented as a stored procedure, not scattered PHP inserts (§8.2)
  • One server-side list contract (page/limit/search/sort/direction + typed filters) implemented once and reused by five different resources — not five bespoke list endpoints (§10)
  • Ownership + management-hierarchy row visibility, enforced in the query layer, not just hidden in the UI (§6)
  • Two independent, explicitly-modeled pipelines (Lead status, Deal stage) connected by one conversion event, each with its own valid-transition rules (§9)

What it is deliberately not

  • Not a marketing-automation platform — no email sequences, no campaign builder (that's the sibling Email Campaign & Delivery Tracker project)
  • Not a billing/quoting system — deals track value and stage, not invoices or line items
  • Not multi-currency or multi-pipeline-per-team — one currency, one pipeline definition, kept simple on purpose
  • Not a telephony/VoIP integration — calls are logged manually as Activities, never actually dialed

3. Technology Stack

Frontend

  • React 19 + TypeScript + Vite (matches the repo's existing scaffold)
  • React Query (server state, cache invalidation)
  • Tailwind CSS
  • React Hook Form + Zod (field-level validation)
  • @dnd-kit for the drag-and-drop Deal/Lead pipeline boards
  • Recharts for the dashboard and reports charts

Backend

  • PHP 8.2 + CodeIgniter 4.7 (matches the repo's existing composer.json)
  • MySQL 8 via MySQLi, list/reporting/conversion logic in stored procedures (§8)
  • firebase/php-jwt for access/refresh tokens
  • zircote/swagger-php for OpenAPI annotations (§26)

Quality & delivery tooling

  • PHPUnit 10 (already scaffolded by CodeIgniter 4) + CIUnitTestCase feature tests
  • PHP-CS-Fixer (PSR-12) + PHPStan level 6
  • Vitest + React Testing Library, Playwright smoke suite
  • Docker + docker-compose (API, MySQL, phpMyAdmin)
  • GitHub Actions CI (lint → static analysis → test → build)

Deployment target

  • Any standard PHP 8.2 + MySQL 8 host — no cloud-vendor lock-in required (§28)
  • Optional AWS path documented for portfolio consistency with sibling projects: EC2/Elastic Beanstalk + RDS MySQL + S3/CloudFront for the SPA build
  • This project's differentiator is full-stack breadth and business-logic correctness, not cloud infrastructure — that ground is covered by the sibling Email Campaign Tracker and Document Manager portfolio pieces

4. Repository & Folder Structure

Decision
Keep the repository's existing top-level shape — backend/ (CodeIgniter 4 appstarter) and frontend/ (Vite React/TS) — and add docs/ and infrastructure/ alongside them.
The repo is already scaffolded with CI4 installed via Composer and Vite React/TS installed via npm, with MySQL already configured in backend/.env against a database named arsi_react_php_saas_crm. This plan fills in app/ and src/.
Why: a reviewer cloning the repo sees standard CodeIgniter 4 and standard Vite conventions immediately — no bespoke layout to learn before the interesting code starts.
react-php-saas-crm/
├── backend/                              # CodeIgniter 4 (already scaffolded)
│   ├── app/
│   │   ├── Config/                       # Routes.php, Filters.php, Database.php, Validation.php
│   │   ├── Controllers/                  # thin: validate via rule group, delegate to Services, return envelope
│   │   │   ├── AuthController.php
│   │   │   ├── UsersController.php
│   │   │   ├── CompaniesController.php
│   │   │   ├── ContactsController.php
│   │   │   ├── LeadsController.php
│   │   │   ├── DealsController.php
│   │   │   ├── TasksController.php
│   │   │   ├── ActivitiesController.php
│   │   │   ├── DashboardController.php
│   │   │   ├── ReportsController.php
│   │   │   └── AuditLogController.php
│   │   ├── Services/                     # business rules + authorization; the only callers of Models/SPs
│   │   │   ├── AuthService.php
│   │   │   ├── CompanyService.php
│   │   │   ├── LeadService.php           # owns the conversion workflow, §9
│   │   │   ├── DealService.php
│   │   │   ├── TaskService.php
│   │   │   └── ReportingService.php
│   │   ├── Models/                       # thin wrappers around `CALL sp_xxx(...)` + simple query-builder reads
│   │   ├── Entities/                     # User, Company, Contact, Lead, Deal, Task, Activity (typed CI4 Entities)
│   │   ├── Libraries/
│   │   │   ├── ListQueryParser.php       # parses page/limit/search/sort/direction — the DataTable contract, §10
│   │   │   └── JwtService.php
│   │   ├── Filters/                      # JwtAuthFilter, RoleFilter, RateLimitFilter, OwnershipScopeFilter
│   │   ├── Exceptions/                   # ApiException base + typed subclasses carrying httpStatus + errorCode
│   │   └── Database/
│   │       ├── Migrations/               # one class per table, §7
│   │       ├── Seeds/                    # DemoSeeder — Brightfield Business Solutions synthetic data
│   │       └── Procedures/                   # raw .sql files for every CREATE PROCEDURE, §8 — run by a migration
│   └── tests/
│       ├── unit/                         # Service-layer unit tests
│       └── api/                          # CIUnitTestCase + FeatureTestTrait, one file per controller
│
├── frontend/                             # React + TypeScript + Vite (already scaffolded)
│   └── src/
│       ├── app/                          # router, providers, layout shell
│       ├── features/
│       │   ├── auth/
│       │   ├── dashboard/
│       │   ├── companies/                # list, detail (tabs: overview/contacts/deals/activity)
│       │   ├── contacts/
│       │   ├── leads/                    # board + detail + convert dialog
│       │   ├── deals/                    # pipeline board + detail
│       │   ├── tasks/
│       │   ├── reports/
│       │   └── users/
│       ├── components/                   # DataTable, KanbanBoard, ActivityTimeline, StatCard, ConfirmDialog...
│       ├── lib/                          # apiClient, queryClient, useListQuery (mirrors §10 on the client)
│       └── types/
│
├── shared/                               # constants shared by both layers (lead status, deal stage, error codes)
├── infrastructure/                       # deployment-related, kept out of application code
│   ├── docker/
│   ├── docker-compose.yml
│   ├── aws/                              # optional Terraform: EC2/EB + RDS + S3/CloudFront, §28
│   └── github-actions/
│
├── docs/                                 # see §32
├── LICENSE                               # MIT (already present)
├── TRADEMARK.md                          # Arsi India Info name/logo notice — see §31
└── README.md                             # already present — expanded per §32

5. Coding Standards & Conventions

Separation of concerns

  • Controller — HTTP concerns only: run the CI4 Validation rule group, apply the ListQueryParser for list endpoints, call one Service method, return the envelope.
  • Service — business rules, authorization/ownership checks, transaction boundaries. Plain PHP classes, unit-testable without booting CodeIgniter's HTTP stack.
  • Model — the only layer that touches the database; multi-table or business-rule-bearing writes call a stored procedure (§8) rather than composing raw SQL.
  • Entity — typed CI4 Entities returned by Models; Controllers never see a raw associative array from the database.

Reusable building blocks

  • ListQueryParser + sp_records_search (§10) — one contract for every paginated/searchable/sortable list in the app
  • ApiResponseTrait — every Controller uses it to build the success/error envelope (§13)
  • OwnershipScopeFilter — resolves the caller's visible owner_id set once per request (self, or self+direct reports for a manager, or all for admin) and injects it into every Service call
  • Shared React components: DataTable, KanbanBoard, ActivityTimeline, StatCard, ConfirmDialog used by every screen in §22

Validation

  • Every endpoint declares a named rule group in app/Config/Validation.php; the Controller validates before touching a Service — no unvalidated input reaches business logic
  • Business rules needing a DB lookup (e.g. "cannot move a Deal to LOST without a reason") live in the stored procedure that performs the write, not in a PHP pre-check that can race
  • The frontend mirrors every constraint with Zod so invalid input never reaches the network call — see §23

Exception handling

  • Typed exceptions (LeadAlreadyConvertedException, InvalidStageTransitionException, ForbiddenRecordException...) extend a base ApiException carrying httpStatus and errorCode
  • A global exception handler converts any thrown exception — typed, validation, or unexpected — into the standard envelope (§13)
  • Stored procedures signal business-rule failures via SIGNAL SQLSTATE '45000' with a structured message the Model layer parses into the matching typed exception (§8)
  • Unexpected errors are logged with a request-correlation id and returned as 500 INTERNAL_ERROR with no stack trace leaked to the client

Security practices

  • Every list/detail/write endpoint applies the ownership scope from §6 at the query level — a record outside the caller's visibility is 404, never merely hidden client-side
  • CORS allow-list, per-user + per-IP rate limiting, and security headers applied via a global Filter
  • Passwords hashed with password_hash() (BCRYPT); JWT access tokens short-lived (15 min), refresh tokens rotated and stored hashed
  • All database access uses parameterized stored-procedure calls or the query builder's bound parameters — no string-concatenated SQL anywhere
  • Secrets (JWT secret, mail credentials) come only from environment variables; backend/.env is git-ignored, only .env.example is committed

Logging & configuration

  • Structured logging via CI4's PSR-3 Logger, with a request-correlation id attached to every log line
  • No direct echo/var_dump debugging left in committed code — a CI lint step greps for it
  • Config (token TTLs, pagination limits, pipeline stage lists) centralized in app/Config/Crm.php, sourced from .env — no hard-coded environment values in business code
  • Per-environment .env.example documented in §32

6. Roles & Ownership-Based Visibility

A CRM's authorization problem is not "can this role see this screen" — it's "can this salesperson see this record." This plan implements that as a first-class, testable concern rather than a UI-only filter.

6.1 Global roles

RoleVisible recordsManage usersView audit log
ADMINEverythingYesYes
SALES_MANAGEROwn records + every rep whose manager_id points to themNoNo
SALES_REPOnly records where owner_id = themselvesNoNo

6.2 How the scope is resolved and enforced

  1. OwnershipScopeFilter runs once per authenticated request, computing the caller's visible owner_id set: [self] for a rep, [self, ...directReports] for a manager (one query against users.manager_id), or null (no restriction) for an ADMIN.
  2. That set is passed as a parameter into every list/search/detail stored-procedure call (§8, §10) — the restriction lives in the SQL WHERE clause, not in a post-fetch PHP filter that could accidentally be skipped on one endpoint.
  3. Companies/Contacts/Leads/Deals/Tasks all carry an owner_id (Tasks use assigned_to); Activities inherit visibility from the record they're logged against.
Guardrail Every list/detail integration test includes a case where a second seeded SALES_REP (no shared manager) requests a record they don't own — asserting 404. A separate test seeds a manager/rep pair and asserts the manager can see the rep's record.

7. Database Schema (MySQL)

7.1 Table inventory

TablePurposeKey relationships
usersAccounts, credentials, role, reporting lineFK manager_idusers (self-ref)
refresh_tokensHashed refresh tokens for rotationFK → users
companiesAccount record — prospect or existing customer (§7.3 decision)FK owner_idusers
contactsPeople belonging to a companyFK → companies, users
leadsUnqualified inbound interest, pre-conversionFK owner_idusers; FK converted_company_id/contact_id/deal_id (nullable, set on conversion)
dealsSales opportunity tied to a companyFK → companies, contacts (nullable), leads (nullable), users
tasksFollow-up to-dos, polymorphically linkedFK assigned_to/created_byusers; app-enforced link to Company/Contact/Lead/Deal (§7.4)
activitiesUnified note/call/email/meeting timeline (§7.3 decision)FK created_byusers; app-enforced polymorphic link
audit_logsImmutable record of every sensitive actionFK → users (nullable, for system actions)

7.2 Full DDL (abbreviated to essential columns)

CREATE TABLE users (
  id              BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  name            VARCHAR(120)     NOT NULL,
  email           VARCHAR(190)     NOT NULL,
  password_hash   VARCHAR(255)     NOT NULL,
  role            ENUM('ADMIN','SALES_MANAGER','SALES_REP') NOT NULL DEFAULT 'SALES_REP',
  manager_id      BIGINT UNSIGNED NULL,
  status          ENUM('ACTIVE','DISABLED') NOT NULL DEFAULT 'ACTIVE',
  created_at      DATETIME         NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at      DATETIME         NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  deleted_at      DATETIME         NULL,
  CONSTRAINT fk_users_manager FOREIGN KEY (manager_id) REFERENCES users(id) ON DELETE SET NULL,
  UNIQUE KEY uq_users_email (email),
  KEY idx_users_manager (manager_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE companies (
  id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  name          VARCHAR(180)     NOT NULL,
  industry      VARCHAR(100)     NULL,
  website       VARCHAR(200)     NULL,
  phone         VARCHAR(30)      NULL,
  status        ENUM('PROSPECT','CUSTOMER','CHURNED') NOT NULL DEFAULT 'PROSPECT',
  owner_id      BIGINT UNSIGNED NOT NULL,
  created_at    DATETIME         NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at    DATETIME         NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  deleted_at    DATETIME         NULL,
  CONSTRAINT fk_company_owner FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE RESTRICT,
  UNIQUE KEY uq_company_name (name),
  KEY idx_company_owner (owner_id),
  FULLTEXT KEY ftx_company_search (name, industry)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE contacts (
  id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  company_id    BIGINT UNSIGNED NOT NULL,
  first_name    VARCHAR(80)      NOT NULL,
  last_name     VARCHAR(80)      NOT NULL,
  email         VARCHAR(190)     NULL,
  phone         VARCHAR(30)      NULL,
  job_title     VARCHAR(100)     NULL,
  owner_id      BIGINT UNSIGNED NOT NULL,
  created_at    DATETIME         NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at    DATETIME         NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  deleted_at    DATETIME         NULL,
  CONSTRAINT fk_contact_company FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE RESTRICT,
  CONSTRAINT fk_contact_owner FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE RESTRICT,
  KEY idx_contact_company (company_id),
  FULLTEXT KEY ftx_contact_search (first_name, last_name, email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE leads (
  id                   BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  first_name           VARCHAR(80)      NOT NULL,
  last_name            VARCHAR(80)      NOT NULL,
  email                VARCHAR(190)     NULL,
  phone                VARCHAR(30)      NULL,
  company_name         VARCHAR(180)     NULL,             -- free text pre-conversion
  source               ENUM('WEBSITE','REFERRAL','COLD_CALL','EVENT','OTHER') NOT NULL DEFAULT 'OTHER',
  status               ENUM('NEW','CONTACTED','QUALIFIED','CONVERTED','DISQUALIFIED') NOT NULL DEFAULT 'NEW',
  owner_id             BIGINT UNSIGNED NOT NULL,
  converted_at         DATETIME         NULL,
  converted_company_id BIGINT UNSIGNED NULL,
  converted_contact_id BIGINT UNSIGNED NULL,
  converted_deal_id    BIGINT UNSIGNED NULL,
  created_at           DATETIME         NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at           DATETIME         NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  deleted_at           DATETIME         NULL,
  CONSTRAINT fk_lead_owner FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE RESTRICT,
  CONSTRAINT fk_lead_company FOREIGN KEY (converted_company_id) REFERENCES companies(id) ON DELETE SET NULL,
  CONSTRAINT fk_lead_contact FOREIGN KEY (converted_contact_id) REFERENCES contacts(id) ON DELETE SET NULL,
  CONSTRAINT fk_lead_deal FOREIGN KEY (converted_deal_id) REFERENCES deals(id) ON DELETE SET NULL,
  KEY idx_lead_owner_status (owner_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE deals (
  id                  BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  company_id          BIGINT UNSIGNED NOT NULL,
  contact_id          BIGINT UNSIGNED NULL,
  lead_id             BIGINT UNSIGNED NULL,
  name                VARCHAR(180)     NOT NULL,
  value_amount        DECIMAL(12,2)   NOT NULL DEFAULT 0.00,
  expected_close_date DATE            NULL,
  stage               ENUM('PROSPECTING','PROPOSAL','NEGOTIATION','WON','LOST') NOT NULL DEFAULT 'PROSPECTING',
  lost_reason         VARCHAR(255)     NULL,
  owner_id            BIGINT UNSIGNED NOT NULL,
  closed_at           DATETIME         NULL,
  created_at          DATETIME         NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at          DATETIME         NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  deleted_at          DATETIME         NULL,
  CONSTRAINT fk_deal_company FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE RESTRICT,
  CONSTRAINT fk_deal_contact FOREIGN KEY (contact_id) REFERENCES contacts(id) ON DELETE SET NULL,
  CONSTRAINT fk_deal_lead FOREIGN KEY (lead_id) REFERENCES leads(id) ON DELETE SET NULL,
  CONSTRAINT fk_deal_owner FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE RESTRICT,
  CONSTRAINT ck_deal_lost_reason CHECK (stage <> 'LOST' OR lost_reason IS NOT NULL),
  KEY idx_deal_owner_stage (owner_id, stage),
  KEY idx_deal_company (company_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

tasks, activities, refresh_tokens, and audit_logs follow the same conventions (audit columns, InnoDB, utf8mb4) and are included in the migration set — omitted here for brevity since they add no new relational pattern beyond what's shown above.

7.3 Design decisions

Decision
"Customers" and "Companies" are one table (companies, with a status of PROSPECT/CUSTOMER/CHURNED), not two.
A customer is simply a company whose status has advanced — the fields (name, industry, contact info) are identical. A separate customers table would just be a shallow copy synchronized by triggers, adding complexity with no portfolio value.
Why: this is exactly the kind of "unnecessary enterprise-level" duplication the brief asks to avoid (§2 of the brief) — real CRMs (HubSpot, Pipedrive) model this the same way.
Decision
"Notes" and "Activities" are one table (activities, with a type of NOTE/CALL/EMAIL/MEETING), not two.
A Note is just an Activity of type NOTE. The frontend's "Notes" tab is the Activity timeline filtered to type=NOTE; the "Activity" tab shows all types.
Why: two tables holding "a timestamped text blob attached to a record" would only differ by a filter condition — one table with a type column is simpler and gives a unified, chronological timeline for free.

7.4 Polymorphic links are application-enforced

tasks.related_to_type/related_to_id and activities.related_to_type/related_to_id cannot carry a real foreign key across four possible target tables — MySQL has no polymorphic FK. Referential integrity here is enforced in the Service layer (the target must exist and be visible to the caller before the insert) and validated by a nightly consistency-check job in non-production seeds; this trade-off is documented rather than hidden, and is the honest alternative to a heavier generic "attachments" join-table design that this portfolio's scope doesn't need.

8. Stored Procedures

Every multi-table or business-rule-bearing write goes through a stored procedure. All follow the same shape: business-rule failures raise SIGNAL SQLSTATE '45000' with a machine-parseable status_code; a DECLARE ... HANDLER FOR SQLEXCEPTION rolls back and reports INTERNAL_ERROR for anything unexpected.

8.1 Pattern A — validated insert with duplicate check (sp_company_create)

DELIMITER $$
CREATE PROCEDURE sp_company_create(
  IN  p_name VARCHAR(180), IN p_industry VARCHAR(100), IN p_website VARCHAR(200),
  IN  p_phone VARCHAR(30), IN p_owner_id BIGINT UNSIGNED,
  OUT p_company_id BIGINT UNSIGNED, OUT p_status_code VARCHAR(30), OUT p_message VARCHAR(255)
)
BEGIN
  DECLARE EXIT HANDLER FOR SQLEXCEPTION
  BEGIN ROLLBACK; SET p_status_code = 'INTERNAL_ERROR', p_message = 'Unexpected database error while creating company.'; END;

  START TRANSACTION;
  IF EXISTS (SELECT 1 FROM companies WHERE name = p_name AND deleted_at IS NULL FOR UPDATE) THEN
    ROLLBACK; SET p_status_code = 'DUPLICATE_NAME', p_message = 'A company with this name already exists.'; LEAVE sp_company_create;
  END IF;

  INSERT INTO companies (name, industry, website, phone, owner_id)
    VALUES (p_name, p_industry, p_website, p_phone, p_owner_id);
  SET p_company_id = LAST_INSERT_ID();

  INSERT INTO audit_logs (user_id, action, entity_type, entity_id, details)
    VALUES (p_owner_id, 'COMPANY_CREATED', 'COMPANY', p_company_id, JSON_OBJECT('name', p_name));

  COMMIT;
  SET p_status_code = 'OK', p_message = 'Company created.';
END$$
DELIMITER ;

8.2 Pattern B — transactional multi-table conversion (sp_lead_convert)

The flagship procedure of this project: converts a qualified Lead into a Company, a Contact, and a Deal atomically. If p_existing_company_id is supplied the lead links to that company instead of creating a duplicate.

DELIMITER $$
CREATE PROCEDURE sp_lead_convert(
  IN  p_lead_id BIGINT UNSIGNED, IN p_existing_company_id BIGINT UNSIGNED,
  IN  p_deal_name VARCHAR(180), IN p_deal_value DECIMAL(12,2), IN p_converted_by BIGINT UNSIGNED,
  OUT p_company_id BIGINT UNSIGNED, OUT p_contact_id BIGINT UNSIGNED, OUT p_deal_id BIGINT UNSIGNED,
  OUT p_status_code VARCHAR(30), OUT p_message VARCHAR(255)
)
BEGIN
  DECLARE v_status VARCHAR(20); DECLARE v_first VARCHAR(80); DECLARE v_last VARCHAR(80);
  DECLARE v_email VARCHAR(190); DECLARE v_phone VARCHAR(30); DECLARE v_company_name VARCHAR(180); DECLARE v_owner BIGINT UNSIGNED;
  DECLARE EXIT HANDLER FOR SQLEXCEPTION
  BEGIN ROLLBACK; SET p_status_code = 'INTERNAL_ERROR', p_message = 'Unexpected database error while converting lead.'; END;

  START TRANSACTION;

  -- lock the lead so it cannot be converted twice by a double-click / race
  SELECT status, first_name, last_name, email, phone, company_name, owner_id
    INTO v_status, v_first, v_last, v_email, v_phone, v_company_name, v_owner
    FROM leads WHERE id = p_lead_id FOR UPDATE;

  IF v_status IS NULL THEN
    ROLLBACK; SET p_status_code = 'NOT_FOUND', p_message = 'Lead not found.'; LEAVE sp_lead_convert;
  ELSEIF v_status = 'CONVERTED' THEN
    ROLLBACK; SET p_status_code = 'ALREADY_CONVERTED', p_message = 'This lead has already been converted.'; LEAVE sp_lead_convert;
  ELSEIF v_status NOT IN ('QUALIFIED') THEN
    ROLLBACK; SET p_status_code = 'NOT_QUALIFIED', p_message = 'Only a qualified lead can be converted.'; LEAVE sp_lead_convert;
  END IF;

  IF p_existing_company_id IS NOT NULL THEN
    SET p_company_id = p_existing_company_id;
  ELSE
    INSERT INTO companies (name, owner_id) VALUES (COALESCE(v_company_name, CONCAT(v_last, ' Household')), v_owner);
    SET p_company_id = LAST_INSERT_ID();
  END IF;

  INSERT INTO contacts (company_id, first_name, last_name, email, phone, owner_id)
    VALUES (p_company_id, v_first, v_last, v_email, v_phone, v_owner);
  SET p_contact_id = LAST_INSERT_ID();

  INSERT INTO deals (company_id, contact_id, lead_id, name, value_amount, stage, owner_id)
    VALUES (p_company_id, p_contact_id, p_lead_id, p_deal_name, p_deal_value, 'PROSPECTING', v_owner);
  SET p_deal_id = LAST_INSERT_ID();

  UPDATE leads SET status = 'CONVERTED', converted_at = NOW(),
    converted_company_id = p_company_id, converted_contact_id = p_contact_id, converted_deal_id = p_deal_id
    WHERE id = p_lead_id;

  INSERT INTO audit_logs (user_id, action, entity_type, entity_id, details)
    VALUES (p_converted_by, 'LEAD_CONVERTED', 'LEAD', p_lead_id,
      JSON_OBJECT('companyId', p_company_id, 'contactId', p_contact_id, 'dealId', p_deal_id));

  COMMIT;
  SET p_status_code = 'OK', p_message = 'Lead converted.';
END$$
DELIMITER ;

8.3 Pattern C — validate-and-branch stage transition (sp_deal_change_stage)

DELIMITER $$
CREATE PROCEDURE sp_deal_change_stage(
  IN  p_deal_id BIGINT UNSIGNED, IN p_new_stage VARCHAR(20), IN p_lost_reason VARCHAR(255), IN p_changed_by BIGINT UNSIGNED,
  OUT p_status_code VARCHAR(30), OUT p_message VARCHAR(255)
)
BEGIN
  DECLARE v_current VARCHAR(20);
  DECLARE EXIT HANDLER FOR SQLEXCEPTION
  BEGIN ROLLBACK; SET p_status_code = 'INTERNAL_ERROR', p_message = 'Unexpected database error while changing stage.'; END;

  START TRANSACTION;
  SELECT stage INTO v_current FROM deals WHERE id = p_deal_id FOR UPDATE;

  IF v_current IN ('WON','LOST') THEN
    ROLLBACK; SET p_status_code = 'INVALID_TRANSITION', p_message = 'A closed deal cannot change stage.'; LEAVE sp_deal_change_stage;
  ELSEIF p_new_stage = 'LOST' AND (p_lost_reason IS NULL OR p_lost_reason = '') THEN
    ROLLBACK; SET p_status_code = 'LOST_REASON_REQUIRED', p_message = 'A reason is required when marking a deal as lost.'; LEAVE sp_deal_change_stage;
  END IF;

  UPDATE deals SET stage = p_new_stage, lost_reason = IF(p_new_stage = 'LOST', p_lost_reason, NULL),
    closed_at = IF(p_new_stage IN ('WON','LOST'), NOW(), NULL)
    WHERE id = p_deal_id;

  IF p_new_stage = 'WON' THEN
    UPDATE companies SET status = 'CUSTOMER' WHERE id = (SELECT company_id FROM deals WHERE id = p_deal_id);
  END IF;

  INSERT INTO audit_logs (user_id, action, entity_type, entity_id, details)
    VALUES (p_changed_by, 'DEAL_STAGE_CHANGED', 'DEAL', p_deal_id, JSON_OBJECT('from', v_current, 'to', p_new_stage));

  COMMIT;
  SET p_status_code = 'OK', p_message = 'Stage updated.';
END$$
DELIMITER ;

8.4 Full procedure index

ProcedurePurposeKey OUT params
sp_user_authenticateFetch credential row by email for loginuser_id, password_hash, role, status
sp_user_inviteAdmin creates a user (duplicate-email checked)user_id, status_code, message
sp_company_createPattern A abovecompany_id, status_code, message
sp_contact_createCreate a contact under a company (validates company exists/visible)contact_id, status_code, message
sp_lead_createCreate a new inbound leadlead_id, status_code, message
sp_lead_convertPattern B abovecompany_id, contact_id, deal_id, status_code, message
sp_lead_disqualifyTerminal transition with a required reasonstatus_code, message
sp_deal_createCreate a deal directly (not via lead conversion)deal_id, status_code, message
sp_deal_change_stagePattern C abovestatus_code, message
sp_task_completeMarks a task done, stamps completed_at (idempotent)status_code, message
sp_dashboard_summaryOne aggregation call backing §22.2 — totals + monthly trend result set, scoped by the ownership set (§6)totalCompanies, newLeadsThisMonth, conversionRate, openDealsCount, openDealsValue, revenueThisMonth (OUT) + trend result set
sp_records_searchThe reusable DataTable search used by Companies/Contacts/Leads/Deals/Tasks (§10)total_count (OUT), result set

9. Lead-to-Deal Conversion & Pipelines

The source brief's single pipeline diagram (New → Contacted → Qualified → Proposal → Won/Lost) is modeled here as two connected pipelines, because that's how conversion actually works once a Lead needs to become billable-account data rather than just a status label — the point where "Qualified" becomes "Proposal" is exactly the point where a Lead needs to become a real Company/Contact/Deal.

9.1 Lead status (pre-conversion)

┌───────┐ ┌────────────┐ ┌────────────┐ ┌───────────┐ │ NEW │───────▶│ CONTACTED │───────▶│ QUALIFIED │──convert──▶│ CONVERTED │ (terminal — see §9.2 for what happens next) └───────┘ └────────────┘ └────────────┘ └───────────┘ │ │ │ └───────────────────┴─────────────────────┴──────────▶ ┌──────────────┐ (any pre-conversion state) │ DISQUALIFIED │ (terminal, requires a reason) └──────────────┘

9.2 Deal stage (post-conversion — matches the brief's "Proposal → Won/Lost")

┌──────────────┐ ┌───────────┐ ┌──────────────┐ │ PROSPECTING │───────▶│ PROPOSAL │───────▶│ NEGOTIATION │ └──────────────┘ └───────────┘ └──────────────┘ │ │ │ └──────────────────────┴─────────────────────┴──────▶ ┌────────┐ (any open stage) │ WON │ (terminal — company.status becomes CUSTOMER) └────────┘ ┌────────┐ (any open stage, reason required) ──▶ │ LOST │ (terminal) └────────┘

A deal created directly (not via conversion, sp_deal_create) starts at PROSPECTING against an existing company; a deal created via sp_lead_convert also starts at PROSPECTING — conversion does not skip straight to PROPOSAL, since "qualified enough to convert" and "has an actual proposal out" are still different moments a rep should track separately.

10. Reusable Server-Side List API

The brief's own "advanced feature" GET /api/companies?page=1&limit=20&search=abc&sort=name&direction=asc — one query-parameter contract, one PHP parser, one stored procedure shape, reused by every list-style resource in this system.

10.1 The contract

GET /api/v1/<resource>?page=1&limit=20&search=<term>&sort=<allow-listed field>&direction=asc|desc&<resource-specific filters>

ListQueryParser reads and validates these five parameters once; each Controller passes the parsed ListQuery object plus its own typed filters (e.g. status for Leads, stage for Deals) straight into sp_records_search, along with an entity_name parameter telling the procedure which table/FULLTEXT index to search.

10.2 Why one procedure instead of five

Decision
sp_records_search takes an entity_name parameter and uses a small internal CASE/dynamic-SQL dispatch (via PREPARE/EXECUTE with parameterized values, never string-built from user input) to search the right table.
Companies, Contacts, Leads, Deals, and Tasks all get identical pagination/search/sort behavior and an identical response shape, so the frontend's single DataTable component and useListQuery hook work unmodified across all five screens.
Why: five near-identical hand-written procedures would drift out of sync the first time someone fixes a pagination bug in one and forgets the other four.

11. Audit Logging

Every action that changes data or access — login, create/update/delete on any core entity, lead conversion, disqualification, deal stage change, task completion, role change — writes one audit_logs row inside the same transaction as the action. An ADMIN can always answer "who changed this, and when" from data alone.

12. API Design Conventions

Base path & versioning

All routes are prefixed /api/v1 and require authentication except /auth/login and /auth/refresh — this CRM has no public-facing surface.

Naming & verbs

Resources are plural nouns (/deals); non-CRUD actions are sub-resource verbs: POST /leads/:id/convert, POST /deals/:id/change-stage, POST /tasks/:id/complete.

13. Response & Error Envelope

13.1 Success — single resource

{ "success": true, "data": { "id": 128, "name": "NovaTrail Logistics", "status": "CUSTOMER" } }

13.2 Success — paginated list

{ "success": true, "data": [ /* ... */ ], "meta": { "page": 1, "limit": 20, "total": 64, "totalPages": 4 } }

13.3 Error

{ "success": false, "error": { "code": "NOT_QUALIFIED", "message": "Only a qualified lead can be converted." } }

14. Error Code Catalog

HTTPCodeMeaning
400VALIDATION_ERRORPayload failed rule-group validation
400LOST_REASON_REQUIREDsp_deal_change_stage — moving to LOST without a reason
401UNAUTHORIZEDMissing or expired access token
403FORBIDDEN_ROLEAuthenticated, but role lacks permission (e.g. non-ADMIN calling user management)
404COMPANY_NOT_FOUNDNo such company, or outside the caller's ownership scope (§6) — identical response either way
404LEAD_NOT_FOUNDNo such lead, or outside the caller's ownership scope
404DEAL_NOT_FOUNDNo such deal, or outside the caller's ownership scope
409DUPLICATE_NAMEA company with this name already exists
409ALREADY_CONVERTEDsp_lead_convert — lead already converted
409NOT_QUALIFIEDsp_lead_convert — lead is not in QUALIFIED status
409INVALID_TRANSITIONStage/status change not allowed from the current state
429RATE_LIMITEDToo many requests from this user/IP in the current window
500INTERNAL_ERRORUnexpected failure — logged with a correlation id, no internals leaked

15. API — Auth & Users

POST/api/v1/auth/login

AuthNone (rate-limited to 10/min/IP)
Validationemail valid format, password non-empty
DB interactionCALL sp_user_authenticate(...); PHP verifies the hash with password_verify()
Success200 OK — access/refresh tokens + user profile (including role and manager_id)
Errors400 VALIDATION_ERROR, 401 UNAUTHORIZED

POST/api/v1/auth/refresh · POST/api/v1/auth/logout · GET/api/v1/users/me

Standard token rotation, revocation, and profile lookup, identical in shape to the sibling portfolio projects' auth endpoints.

POST/api/v1/users · GET/api/v1/users · PUT/api/v1/users/:id

ADMIN Invite, list, and update role/status/manager_id for team members. Changing a rep's manager_id takes effect on their very next request (§6.2 recomputes the scope per-request, nothing to cache-invalidate).

16. API — Companies & Contacts

POST/api/v1/companies

AuthBearer JWT, any role
Validationname 2-180 chars, unique; industry/website/phone optional
DB interactionCALL sp_company_create(...) (§8.1)
Success201 Created + company object; caller becomes owner_id
Errors400 VALIDATION_ERROR, 409 DUPLICATE_NAME

GET/api/v1/companies

The reusable list contract (§10): ?search=&status=&sort=name|createdAt&direction=&page=&limit=.

GET/api/v1/companies/:id

Detail plus counts of related contacts/open deals — backs the tabbed detail screen (§22.4).

PUT/api/v1/companies/:id · DELETE/api/v1/companies/:id

Update fields; soft delete is blocked (409) while any non-closed deal references the company.

Contacts

MethodPathPurpose
POST/api/v1/contactsCreate (requires companyId)
GET/api/v1/contactsList contract (§10), optional companyId filter
GET/api/v1/contacts/:idDetail
PUT/api/v1/contacts/:idUpdate
DELETE/api/v1/contacts/:idSoft delete

17. API — Leads & Conversion

POST/api/v1/leads · GET/api/v1/leads · GET/api/v1/leads/:id · PUT/api/v1/leads/:id

Standard CRUD, list contract (§10) with a status filter for the Kanban board (§22.5).

POST/api/v1/leads/:id/convert

AuthBearer JWT; caller must own the lead (or be its manager/admin)
ValidationdealName required; dealValue ≥ 0; existingCompanyId optional (must exist/be visible if supplied)
DB interactionCALL sp_lead_convert(...) (§8.2)
Success200 OK + { company, contact, deal }
Errors400 VALIDATION_ERROR, 404 LEAD_NOT_FOUND, 409 ALREADY_CONVERTED, 409 NOT_QUALIFIED

POST/api/v1/leads/:id/disqualify

Requires a reason (1-255 chars); backed by sp_lead_disqualify.

18. API — Deals & Pipeline

POST/api/v1/deals · GET/api/v1/deals · GET/api/v1/deals/:id · PUT/api/v1/deals/:id

Standard CRUD, list contract (§10) with a stage filter for the pipeline board (§22.6).

POST/api/v1/deals/:id/change-stage

AuthBearer JWT; caller must own the deal (or be its manager/admin)
Validationstage one of PROSPECTING/PROPOSAL/NEGOTIATION/WON/LOST; lostReason required when stage=LOST
DB interactionCALL sp_deal_change_stage(...) (§8.3) — also flips the parent company to CUSTOMER on WON
Success200 OK + updated deal
Errors400 VALIDATION_ERROR, 400 LOST_REASON_REQUIRED, 404 DEAL_NOT_FOUND, 409 INVALID_TRANSITION

19. API — Tasks, Activities, Dashboard & Reports

MethodPathPurpose
POST/api/v1/tasksCreate (subject, dueDate, priority, relatedToType/Id, assignedTo)
GET/api/v1/tasksList contract (§10); default filter: assigned to me, open, due soonest first
PUT/api/v1/tasks/:idEdit
POST/api/v1/tasks/:id/completeMarks done (sp_task_complete, idempotent)
POST/api/v1/activitiesLog a note/call/email/meeting against any Company/Contact/Lead/Deal
GET/api/v1/activitiesrelatedToType+relatedToId required — backs the timeline component (§21)
GET/api/v1/dashboard/summaryCALL sp_dashboard_summary(...) — backs §22.2
GET/api/v1/reports/pipeline-by-stageOpen deal count + value per stage
GET/api/v1/reports/salesperson-performancePer-rep: deals won, revenue, conversion rate (ADMIN/manager only, scoped to their team)
GET/api/v1/reports/monthly-salesWon-deal revenue by month, last 12 months
GET/api/v1/audit-logsADMIN Paginated audit trail

20. Full Endpoint Index

MethodPathRolesPurpose
POST/api/v1/auth/loginPublicAuthenticate
POST/api/v1/auth/refreshPublicRotate tokens
POST/api/v1/auth/logoutAnyRevoke refresh token
GET/api/v1/users/meAnyCurrent profile
POST/api/v1/usersADMINInvite user
GET/api/v1/usersADMINList team
PUT/api/v1/users/:idADMINUpdate role/status/manager
POST/api/v1/companiesAnyCreate company
GET/api/v1/companiesAny (scoped)List (§10)
GET/api/v1/companies/:idAny (scoped)Detail
PUT/api/v1/companies/:idOwner/manager/adminUpdate
DELETE/api/v1/companies/:idOwner/manager/adminSoft delete
POST/api/v1/contactsAnyCreate contact
GET/api/v1/contactsAny (scoped)List (§10)
GET/api/v1/contacts/:idAny (scoped)Detail
PUT/api/v1/contacts/:idOwner/manager/adminUpdate
DELETE/api/v1/contacts/:idOwner/manager/adminSoft delete
POST/api/v1/leadsAnyCreate lead
GET/api/v1/leadsAny (scoped)List/board (§10)
GET/api/v1/leads/:idAny (scoped)Detail
PUT/api/v1/leads/:idOwner/manager/adminUpdate
POST/api/v1/leads/:id/convertOwner/manager/adminConvert to Company+Contact+Deal
POST/api/v1/leads/:id/disqualifyOwner/manager/adminTerminal, reason required
POST/api/v1/dealsAnyCreate deal directly
GET/api/v1/dealsAny (scoped)List/board (§10)
GET/api/v1/deals/:idAny (scoped)Detail
PUT/api/v1/deals/:idOwner/manager/adminUpdate fields
POST/api/v1/deals/:id/change-stageOwner/manager/adminMove pipeline stage
POST/api/v1/tasksAnyCreate task
GET/api/v1/tasksAny (scoped)List (§10)
PUT/api/v1/tasks/:idAssignee/manager/adminEdit
POST/api/v1/tasks/:id/completeAssignee/manager/adminComplete
POST/api/v1/activitiesAnyLog an activity
GET/api/v1/activitiesAny (scoped)Timeline for a record
GET/api/v1/dashboard/summaryAny (scoped)KPI cards + trend
GET/api/v1/reports/pipeline-by-stageAny (scoped)Funnel report
GET/api/v1/reports/salesperson-performanceManager/adminLeaderboard report
GET/api/v1/reports/monthly-salesAny (scoped)Revenue trend
GET/api/v1/audit-logsADMINAudit trail

21. Frontend Architecture

21.1 Data flow

React Component ──▶ React Query hook (useCompanies, useLeads, useDeals...) │ ▼ lib/apiClient (axios, attaches Bearer token, refreshes on 401) │ ▼ PHP CodeIgniter 4 REST API │ (mutation success invalidates the relevant query key — converting a lead invalidates ['leads'], ['companies'], ['deals'] together)

21.2 One list hook, five screens

Decision
A single useListQuery(resource, params) hook and a single <DataTable/> component drive Companies, Contacts, Leads (table view), Deals (table view), and Tasks.
Kanban board views (Leads, Deals) reuse the same hook for data-fetching and layer a @dnd-kit grouping-by-column on top — the data layer doesn't know or care whether it's rendered as a table or a board.
Why: mirrors the backend's one-procedure-many-resources decision (§10) — the frontend and backend share the same "don't repeat the list logic" philosophy end to end.

22. Screens & UI/UX Specification

Eleven screens, scoped to only the fields the corresponding record actually needs (§2) — no custom-field builder, no multi-currency, no quote/invoice screens.

22.1 Login
/login

Fields: email* (format), password* (non-empty). No self-registration — accounts come from an ADMIN invite.

22.2 Dashboard
/dashboard

KPI cards (Total Customers, New Leads This Month, Conversion Rate, Open Deals — count & value, Revenue This Month), a monthly-sales trend chart, and a salesperson leaderboard (managers/admin only). Empty: "No activity yet this month" under any zero-value KPI, never a blank card.

┌──────────────────────────────────────────────────────────────┐ │ Customers New Leads Conv. Rate Open Deals Revenue MTD │ │ 86 14 32% 18 · $94K $41K │ ├──────────────────────────────────────────────────────────────┤ │ Monthly Sales — last 12 months │ Top Salespeople │ │ ▁▂▃▅▆▇█▆▅▃▂▁ │ 1. Priya S. $28K won │ │ │ 2. Arjun M. $19K won │ └──────────────────────────────────────────────────────────────┘
22.3 Companies List & Detail
/companies · /companies/:id

List columns: Name, Industry, Status badge, Owner, Open Deals, Last Activity. Detail tabs: Overview (fields below), Contacts, Deals, Activity (timeline, filterable to Notes only).

Fields: name* (2-180 chars, unique), industry (optional), website (optional, URL format), phone (optional).

22.4 Contacts
/contacts (global list) — also embedded in Company Detail

Fields: firstName*, lastName*, email (optional, format validated), phone (optional), jobTitle (optional), company* (searchable select, required).

22.5 Leads Board
/leads

Kanban columns: New, Contacted, Qualified, Disqualified (Converted leads drop off the board into their resulting Deal). Drag a card into Qualified enables the "Convert" action; dragging into Disqualified opens a required-reason dialog before the move commits.

New Lead fields: firstName*, lastName*, email (optional), phone (optional), companyName (optional, free text), source* (select).

Convert dialog fields: dealName* (pre-filled as "{companyName} — New Business"), dealValue (optional, ≥ 0), link to an existing company (optional searchable select, otherwise a new company is created).

22.6 Deals Pipeline Board
/deals

Kanban columns: Prospecting, Proposal, Negotiation, Won, Lost — each column header shows count and total value. Dragging into Lost opens a required-reason dialog; dragging into Won shows a small celebratory confirm ("Mark {company} as a customer?").

22.7 Tasks
/tasks

Defaults to "My open tasks, due soonest first." Fields: subject* (1-200 chars), dueDate (optional), priority* (Low/Medium/High), relatedTo (optional searchable select across Companies/Contacts/Leads/Deals), assignedTo* (defaults to self; managers can assign to their reps).

Empty: "Nothing due — nice work" (distinct, positive framing rather than a generic empty state).

22.8 Activity Timeline (embedded component)
used inside Company/Contact/Lead/Deal Detail

Log fields: type* (Note/Call/Email/Meeting), subject (optional), body* (1-2000 chars), occurredAt (defaults to now, editable for backfilling a call log). Newest first, grouped by day.

22.9 Reports
/reports

Pipeline-by-stage funnel chart, monthly sales trend, and (manager/admin only) a salesperson performance table. Empty: "Not enough closed deals yet to show performance" rather than an empty chart.

22.10 User Management
/admin/users — ADMIN only

Invite fields: name*, email* (unique), role* (Admin/Sales Manager/Sales Rep), manager (searchable select, required when role = Sales Rep).

22.11 Audit Log
/admin/audit-log — ADMIN only

Filterable table: date range, action, entity type, user. Each row expands to show the JSON details payload.

23. UI & Validation Standards

24. Pagination, Filtering & Sorting

GET /api/v1/deals?stage=NEGOTIATION&page=1&limit=20&search=nova&sort=value_amount&direction=desc

This is the client-facing half of §10 — every list endpoint shares the same five base parameters plus its own allow-listed resource-specific filters, and every response follows the §13.2 paginated shape, so <DataTable/> and useListQuery work unmodified across Companies, Contacts, Leads, Deals, and Tasks.

25. Testing Strategy

Backend

  • Unit tests per Service (PHPUnit) — ownership-scope resolution (§6.2), stage/status transition rules
  • Feature tests (CIUnitTestCase + FeatureTestTrait) per Controller against a MySQL test database with real stored procedures loaded
  • A dedicated test for sp_lead_convert asserting a double-submit (two concurrent convert calls on the same lead) never creates two companies/deals — proves the row lock in §8.2
  • The ownership guardrail tests from §6.2 (rep can't see another rep's record; manager can see their report's record)

Frontend

  • Vitest + React Testing Library for useListQuery, the Convert dialog's validation, and the Kanban drag-to-Lost required-reason flow
  • MSW mocks the API — no real network calls in component tests
  • Playwright smoke suite: login → create a lead → qualify it → convert it → drag the resulting deal to Won → confirm the company shows as a Customer on the dashboard

26. API Documentation (OpenAPI)

Generated from PHP doc-comment annotations via zircote/swagger-php, served at /api/docs in non-production environments. Every route's rule group is mirrored in its @OA\RequestBody schema so the spec cannot drift from the actual validation enforced in §12–§19.

27. Docker & Local Development

# infrastructure/docker-compose.yml services
api    # PHP-FPM + CodeIgniter 4, port 8080
web    # nginx serving the built React app in dev-parity mode
mysql  # MySQL 8, auto-runs migrations + stored-procedure scripts + DemoSeeder on first boot
mailhog # catches the ADMIN user-invite emails

docker compose up brings up the full stack; php spark db:seed DemoSeeder loads the fictional "Brightfield Business Solutions" sales team with sample companies, contacts, a lead in every status, and deals across every stage for screenshots.

28. Deployment Architecture

┌────────────────────┐ │ Any PHP 8.2 + MySQL │ ◀── the only hard requirement │ 8 host (shared, VPS,│ │ or managed platform)│ └────────────────────┘ │ │ optional, for portfolio consistency with sibling AWS-based projects: ▼ ┌───────────────────────────────────────────────────────────┐ │ CloudFront ──▶ S3 (React build) │ │ EC2 / Elastic Beanstalk (PHP-FPM + CodeIgniter 4) ──▶ RDS MySQL │ └───────────────────────────────────────────────────────────┘

Unlike the sibling Email Campaign Tracker and Document Manager projects, this CRM has no queue, no object storage, and no webhook surface to justify a cloud-native architecture — it is intentionally deployable anywhere PHP and MySQL run, which is itself a realistic and common small-business deployment target worth demonstrating.

29. Build Phases & Roadmap

Phase 0
Foundation
~4 days
  • Migrations for all 9 tables + stored procedures
  • Auth (login/refresh) + JWT filter, roles
  • Docker Compose (MySQL, Mailhog)
Phase 1
Accounts
~4 days
  • Companies + Contacts CRUD
  • ListQueryParser + sp_records_search (§10) built here first, reused everywhere after
Phase 2
Pipeline core
~1 week
  • Leads CRUD + board, disqualify
  • Deals CRUD + board, change-stage
  • sp_lead_convert — the flagship feature
Phase 3
Ownership & visibility
~4 days
  • OwnershipScopeFilter, manager/rep hierarchy
  • Guardrail tests (§6.2, §25)
Phase 4
Activity & tasks
~4 days
  • Tasks CRUD + complete
  • Activities timeline (unified notes/calls/emails/meetings)
Phase 5
Dashboard & reports
~4 days
  • sp_dashboard_summary + KPI cards
  • Pipeline/monthly/leaderboard reports
Phase 6
Portfolio polish
~3 days
  • README, screenshots, ER diagram export
  • Seed data, demo script, OpenAPI polish

30. Risks & Mitigations

RiskMitigation
A rep sees another rep's pipeline through a missed scope check on one endpointOwnership scope is applied inside the shared stored procedures (§6.2, §8.4), not re-implemented per Controller; guardrail tests run against every list/detail endpoint (§25)
Double-clicking "Convert" creates duplicate companies/dealsSELECT ... FOR UPDATE row lock + a CONVERTED terminal status inside sp_lead_convert (§8.2), covered by a concurrency test
Dynamic SQL in sp_records_search (§10.2) opens an injection surfaceOnly the table/column identifiers come from an allow-listed entity_name map inside the procedure; all values are bound parameters via PREPARE/EXECUTE, never string-concatenated
Reviewer expects cloud infrastructure like the sibling projects§2 and §28 explicitly explain why this project's value is full-stack/business-logic depth, not cloud architecture

31. Copyright, Trademark & Branding

Arsi India Info
Arsi India Info — Innovate · Integrate · Elevate.
Author of record for this repository and every file in it.

31.1 Two-layer licensing

Source code is released under the MIT License (already present at the repo root). The Arsi India Info name and logo are covered separately by TRADEMARK.md: forks and derivatives are welcome, but must not present themselves as Arsi India Info's own product or reuse its logo/branding.

31.2 Where the signature lives

SurfaceBranding element
FrontendPersistent <BrandFooter/> component on every page: "© 2026 Arsi India Info"
APIX-Powered-By: Arsi-India-Info response header on every route
APIGET /api/v1/about — public endpoint returning project + author metadata
OpenAPIinfo.contact and info.license fields point to Arsi India Info
Source filesCopyright header banner in every PHP/TS file, enforced by a CI license-header-check step
This documentSidebar, hero seal, and footer (below)

31.3 Honest limits of watermarking a public repo

A footer string or logo can be stripped by a determined fork. Real proof of authorship comes from GitHub itself: commits under the Arsi India Info GitHub org, signed commits, a CODEOWNERS file, and SHA-256 checksums on tagged releases. This plan treats visible branding as presentation, not enforcement.

32. Documentation Set & Portfolio Usage

DocumentLocationCovers
README.mdrepo root (already present, expanded)Pitch, feature list, architecture diagram, screenshots, quick start
API docs/api/docs (OpenAPI UI) + docs/api.md exportEvery endpoint in §15–§20
Frontend startupdocs/frontend-setup.mdnpm install, .env.example, npm run dev
Backend startupdocs/backend-setup.mdcomposer install, .env setup, migrations + stored procedures, php spark serve
Database referencedocs/data-model.mdTable inventory (§7.1), ER diagram, stored-procedure index (§8.4)
Testing guidedocs/testing.mdcomposer test, npm run test, coverage thresholds
Deployment guidedocs/deployment.mdAny PHP/MySQL host (primary) and optional AWS path — §27, §28
Git/GitHub workflowdocs/contributing.mdBranch naming, Conventional Commits, PR template, required CI checks
Portfolio/demo usagedocs/portfolio-demo.mdA 5-minute walkthrough: seed data, qualify and convert a lead, drag the resulting deal to Won, see the dashboard update

33. Definition of Done