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.
1. Executive Summary
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.
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-kitfor 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
backend/ (CodeIgniter 4 appstarter) and frontend/ (Vite React/TS) — and add docs/ and infrastructure/ alongside them.backend/.env against a database named arsi_react_php_saas_crm. This plan fills in app/ and src/.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
ListQueryParserfor 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 appApiResponseTrait— every Controller uses it to build the success/error envelope (§13)OwnershipScopeFilter— resolves the caller's visibleowner_idset 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,ConfirmDialogused 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 baseApiExceptioncarryinghttpStatusanderrorCode - 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_ERRORwith 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/.envis git-ignored, only.env.exampleis 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_dumpdebugging 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.exampledocumented 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
| Role | Visible records | Manage users | View audit log |
|---|---|---|---|
| ADMIN | Everything | Yes | Yes |
| SALES_MANAGER | Own records + every rep whose manager_id points to them | No | No |
| SALES_REP | Only records where owner_id = themselves | No | No |
6.2 How the scope is resolved and enforced
OwnershipScopeFilterruns once per authenticated request, computing the caller's visibleowner_idset:[self]for a rep,[self, ...directReports]for a manager (one query againstusers.manager_id), ornull(no restriction) for an ADMIN.- That set is passed as a parameter into every list/search/detail stored-procedure call (§8, §10) — the restriction lives in the SQL
WHEREclause, not in a post-fetch PHP filter that could accidentally be skipped on one endpoint. - Companies/Contacts/Leads/Deals/Tasks all carry an
owner_id(Tasks useassigned_to); Activities inherit visibility from the record they're logged against.
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
| Table | Purpose | Key relationships |
|---|---|---|
users | Accounts, credentials, role, reporting line | FK manager_id → users (self-ref) |
refresh_tokens | Hashed refresh tokens for rotation | FK → users |
companies | Account record — prospect or existing customer (§7.3 decision) | FK owner_id → users |
contacts | People belonging to a company | FK → companies, users |
leads | Unqualified inbound interest, pre-conversion | FK owner_id → users; FK converted_company_id/contact_id/deal_id (nullable, set on conversion) |
deals | Sales opportunity tied to a company | FK → companies, contacts (nullable), leads (nullable), users |
tasks | Follow-up to-dos, polymorphically linked | FK assigned_to/created_by → users; app-enforced link to Company/Contact/Lead/Deal (§7.4) |
activities | Unified note/call/email/meeting timeline (§7.3 decision) | FK created_by → users; app-enforced polymorphic link |
audit_logs | Immutable record of every sensitive action | FK → 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
companies, with a status of PROSPECT/CUSTOMER/CHURNED), not two.customers table would just be a shallow copy synchronized by triggers, adding complexity with no portfolio value.activities, with a type of NOTE/CALL/EMAIL/MEETING), not two.type=NOTE; the "Activity" tab shows all types.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
| Procedure | Purpose | Key OUT params |
|---|---|---|
sp_user_authenticate | Fetch credential row by email for login | user_id, password_hash, role, status |
sp_user_invite | Admin creates a user (duplicate-email checked) | user_id, status_code, message |
sp_company_create | Pattern A above | company_id, status_code, message |
sp_contact_create | Create a contact under a company (validates company exists/visible) | contact_id, status_code, message |
sp_lead_create | Create a new inbound lead | lead_id, status_code, message |
sp_lead_convert | Pattern B above | company_id, contact_id, deal_id, status_code, message |
sp_lead_disqualify | Terminal transition with a required reason | status_code, message |
sp_deal_create | Create a deal directly (not via lead conversion) | deal_id, status_code, message |
sp_deal_change_stage | Pattern C above | status_code, message |
sp_task_complete | Marks a task done, stamps completed_at (idempotent) | status_code, message |
sp_dashboard_summary | One 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_search | The 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)
9.2 Deal stage (post-conversion — matches the brief's "Proposal → Won/Lost")
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
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
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.DataTable component and useListQuery hook work unmodified across all five screens.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
| HTTP | Code | Meaning |
|---|---|---|
| 400 | VALIDATION_ERROR | Payload failed rule-group validation |
| 400 | LOST_REASON_REQUIRED | sp_deal_change_stage — moving to LOST without a reason |
| 401 | UNAUTHORIZED | Missing or expired access token |
| 403 | FORBIDDEN_ROLE | Authenticated, but role lacks permission (e.g. non-ADMIN calling user management) |
| 404 | COMPANY_NOT_FOUND | No such company, or outside the caller's ownership scope (§6) — identical response either way |
| 404 | LEAD_NOT_FOUND | No such lead, or outside the caller's ownership scope |
| 404 | DEAL_NOT_FOUND | No such deal, or outside the caller's ownership scope |
| 409 | DUPLICATE_NAME | A company with this name already exists |
| 409 | ALREADY_CONVERTED | sp_lead_convert — lead already converted |
| 409 | NOT_QUALIFIED | sp_lead_convert — lead is not in QUALIFIED status |
| 409 | INVALID_TRANSITION | Stage/status change not allowed from the current state |
| 429 | RATE_LIMITED | Too many requests from this user/IP in the current window |
| 500 | INTERNAL_ERROR | Unexpected failure — logged with a correlation id, no internals leaked |
15. API — Auth & Users
POST/api/v1/auth/login
| Auth | None (rate-limited to 10/min/IP) |
| Validation | email valid format, password non-empty |
| DB interaction | CALL sp_user_authenticate(...); PHP verifies the hash with password_verify() |
| Success | 200 OK — access/refresh tokens + user profile (including role and manager_id) |
| Errors | 400 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
| Auth | Bearer JWT, any role |
| Validation | name 2-180 chars, unique; industry/website/phone optional |
| DB interaction | CALL sp_company_create(...) (§8.1) |
| Success | 201 Created + company object; caller becomes owner_id |
| Errors | 400 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
| Method | Path | Purpose |
|---|---|---|
| POST | /api/v1/contacts | Create (requires companyId) |
| GET | /api/v1/contacts | List contract (§10), optional companyId filter |
| GET | /api/v1/contacts/:id | Detail |
| PUT | /api/v1/contacts/:id | Update |
| DELETE | /api/v1/contacts/:id | Soft 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
| Auth | Bearer JWT; caller must own the lead (or be its manager/admin) |
| Validation | dealName required; dealValue ≥ 0; existingCompanyId optional (must exist/be visible if supplied) |
| DB interaction | CALL sp_lead_convert(...) (§8.2) |
| Success | 200 OK + { company, contact, deal } |
| Errors | 400 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
| Auth | Bearer JWT; caller must own the deal (or be its manager/admin) |
| Validation | stage one of PROSPECTING/PROPOSAL/NEGOTIATION/WON/LOST; lostReason required when stage=LOST |
| DB interaction | CALL sp_deal_change_stage(...) (§8.3) — also flips the parent company to CUSTOMER on WON |
| Success | 200 OK + updated deal |
| Errors | 400 VALIDATION_ERROR, 400 LOST_REASON_REQUIRED, 404 DEAL_NOT_FOUND, 409 INVALID_TRANSITION |
19. API — Tasks, Activities, Dashboard & Reports
| Method | Path | Purpose |
|---|---|---|
| POST | /api/v1/tasks | Create (subject, dueDate, priority, relatedToType/Id, assignedTo) |
| GET | /api/v1/tasks | List contract (§10); default filter: assigned to me, open, due soonest first |
| PUT | /api/v1/tasks/:id | Edit |
| POST | /api/v1/tasks/:id/complete | Marks done (sp_task_complete, idempotent) |
| POST | /api/v1/activities | Log a note/call/email/meeting against any Company/Contact/Lead/Deal |
| GET | /api/v1/activities | relatedToType+relatedToId required — backs the timeline component (§21) |
| GET | /api/v1/dashboard/summary | CALL sp_dashboard_summary(...) — backs §22.2 |
| GET | /api/v1/reports/pipeline-by-stage | Open deal count + value per stage |
| GET | /api/v1/reports/salesperson-performance | Per-rep: deals won, revenue, conversion rate (ADMIN/manager only, scoped to their team) |
| GET | /api/v1/reports/monthly-sales | Won-deal revenue by month, last 12 months |
| GET | /api/v1/audit-logs | ADMIN Paginated audit trail |
20. Full Endpoint Index
| Method | Path | Roles | Purpose |
|---|---|---|---|
| POST | /api/v1/auth/login | Public | Authenticate |
| POST | /api/v1/auth/refresh | Public | Rotate tokens |
| POST | /api/v1/auth/logout | Any | Revoke refresh token |
| GET | /api/v1/users/me | Any | Current profile |
| POST | /api/v1/users | ADMIN | Invite user |
| GET | /api/v1/users | ADMIN | List team |
| PUT | /api/v1/users/:id | ADMIN | Update role/status/manager |
| POST | /api/v1/companies | Any | Create company |
| GET | /api/v1/companies | Any (scoped) | List (§10) |
| GET | /api/v1/companies/:id | Any (scoped) | Detail |
| PUT | /api/v1/companies/:id | Owner/manager/admin | Update |
| DELETE | /api/v1/companies/:id | Owner/manager/admin | Soft delete |
| POST | /api/v1/contacts | Any | Create contact |
| GET | /api/v1/contacts | Any (scoped) | List (§10) |
| GET | /api/v1/contacts/:id | Any (scoped) | Detail |
| PUT | /api/v1/contacts/:id | Owner/manager/admin | Update |
| DELETE | /api/v1/contacts/:id | Owner/manager/admin | Soft delete |
| POST | /api/v1/leads | Any | Create lead |
| GET | /api/v1/leads | Any (scoped) | List/board (§10) |
| GET | /api/v1/leads/:id | Any (scoped) | Detail |
| PUT | /api/v1/leads/:id | Owner/manager/admin | Update |
| POST | /api/v1/leads/:id/convert | Owner/manager/admin | Convert to Company+Contact+Deal |
| POST | /api/v1/leads/:id/disqualify | Owner/manager/admin | Terminal, reason required |
| POST | /api/v1/deals | Any | Create deal directly |
| GET | /api/v1/deals | Any (scoped) | List/board (§10) |
| GET | /api/v1/deals/:id | Any (scoped) | Detail |
| PUT | /api/v1/deals/:id | Owner/manager/admin | Update fields |
| POST | /api/v1/deals/:id/change-stage | Owner/manager/admin | Move pipeline stage |
| POST | /api/v1/tasks | Any | Create task |
| GET | /api/v1/tasks | Any (scoped) | List (§10) |
| PUT | /api/v1/tasks/:id | Assignee/manager/admin | Edit |
| POST | /api/v1/tasks/:id/complete | Assignee/manager/admin | Complete |
| POST | /api/v1/activities | Any | Log an activity |
| GET | /api/v1/activities | Any (scoped) | Timeline for a record |
| GET | /api/v1/dashboard/summary | Any (scoped) | KPI cards + trend |
| GET | /api/v1/reports/pipeline-by-stage | Any (scoped) | Funnel report |
| GET | /api/v1/reports/salesperson-performance | Manager/admin | Leaderboard report |
| GET | /api/v1/reports/monthly-sales | Any (scoped) | Revenue trend |
| GET | /api/v1/audit-logs | ADMIN | Audit trail |
21. Frontend Architecture
21.1 Data flow
21.2 One list hook, five screens
useListQuery(resource, params) hook and a single <DataTable/> component drive Companies, Contacts, Leads (table view), Deals (table view), and Tasks.@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.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.
/loginFields: email* (format), password* (non-empty). No self-registration — accounts come from an ADMIN invite.
/dashboardKPI 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.
/companies · /companies/:idList 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).
/contacts (global list) — also embedded in Company DetailFields: firstName*, lastName*, email (optional, format validated), phone (optional), jobTitle (optional), company* (searchable select, required).
/leadsKanban 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).
/dealsKanban 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?").
/tasksDefaults 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).
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.
/reportsPipeline-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.
/admin/users — ADMIN onlyInvite fields: name*, email* (unique), role* (Admin/Sales Manager/Sales Rep), manager (searchable select, required when role = Sales Rep).
/admin/audit-log — ADMIN onlyFilterable table: date range, action, entity type, user. Each row expands to show the JSON details payload.
23. UI & Validation Standards
- Required fields marked with a trailing
*; the same Zod schema used client-side mirrors the CI4 rule group server-side. - Scope-aware UI: a Sales Rep never sees another rep's records in any list — there is no "access denied" screen to design for the normal flow, only the §6 guardrail case (a stale link to someone else's record) rendered as a normal 404 "not found" page.
- Loading state: skeleton rows/cards/board columns matching the real layout.
- Empty vs. no-results: "You haven't added any companies yet" (creation CTA) is always distinct from "No companies match your search" (clear-filters action).
- Destructive/terminal actions (delete, disqualify, mark lost, disable user) always confirm by naming the record and, where applicable, require the reason field before the action is enabled.
- Toasts confirm every successful mutation ("Lead converted — deal created", "Task completed"); field errors surface inline first.
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_convertasserting 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
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
- Migrations for all 9 tables + stored procedures
- Auth (login/refresh) + JWT filter, roles
- Docker Compose (MySQL, Mailhog)
- Companies + Contacts CRUD
ListQueryParser+sp_records_search(§10) built here first, reused everywhere after
- Leads CRUD + board, disqualify
- Deals CRUD + board, change-stage
sp_lead_convert— the flagship feature
OwnershipScopeFilter, manager/rep hierarchy- Guardrail tests (§6.2, §25)
- Tasks CRUD + complete
- Activities timeline (unified notes/calls/emails/meetings)
sp_dashboard_summary+ KPI cards- Pipeline/monthly/leaderboard reports
- README, screenshots, ER diagram export
- Seed data, demo script, OpenAPI polish
30. Risks & Mitigations
| Risk | Mitigation |
|---|---|
| A rep sees another rep's pipeline through a missed scope check on one endpoint | Ownership 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/deals | SELECT ... 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 surface | Only 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
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
| Surface | Branding element |
|---|---|
| Frontend | Persistent <BrandFooter/> component on every page: "© 2026 Arsi India Info" |
| API | X-Powered-By: Arsi-India-Info response header on every route |
| API | GET /api/v1/about — public endpoint returning project + author metadata |
| OpenAPI | info.contact and info.license fields point to Arsi India Info |
| Source files | Copyright header banner in every PHP/TS file, enforced by a CI license-header-check step |
| This document | Sidebar, 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
| Document | Location | Covers |
|---|---|---|
| README.md | repo root (already present, expanded) | Pitch, feature list, architecture diagram, screenshots, quick start |
| API docs | /api/docs (OpenAPI UI) + docs/api.md export | Every endpoint in §15–§20 |
| Frontend startup | docs/frontend-setup.md | npm install, .env.example, npm run dev |
| Backend startup | docs/backend-setup.md | composer install, .env setup, migrations + stored procedures, php spark serve |
| Database reference | docs/data-model.md | Table inventory (§7.1), ER diagram, stored-procedure index (§8.4) |
| Testing guide | docs/testing.md | composer test, npm run test, coverage thresholds |
| Deployment guide | docs/deployment.md | Any PHP/MySQL host (primary) and optional AWS path — §27, §28 |
| Git/GitHub workflow | docs/contributing.md | Branch naming, Conventional Commits, PR template, required CI checks |
| Portfolio/demo usage | docs/portfolio-demo.md | A 5-minute walkthrough: seed data, qualify and convert a lead, drag the resulting deal to Won, see the dashboard update |
33. Definition of Done
- All 36 endpoints in §20 implemented, validated, and covered by at least one feature test
- Every screen in §22 has loading, empty, error, and success states implemented — not just the happy path
- A full lead-to-revenue lifecycle (create lead → qualify → convert → move deal through every stage to Won → dashboard/reports reflect it) is demonstrable end-to-end with one seed script and no manual DB edits
- The ownership-visibility guardrail tests (§6.2) and the lead-conversion concurrency test (§25) both pass in CI
- The same list contract (§10) demonstrably powers Companies, Contacts, Leads, Deals, and Tasks with no per-resource pagination code duplicated in the frontend
- README, API docs, and the portfolio demo script (§32) are complete enough that someone who has never seen the repo can run it in under 10 minutes
- Branding present per the §31.2 table; LICENSE and TRADEMARK.md committed