# Phase 3 Summary — The Complete CRM

Built on Phase 1 (architecture) and Phase 2 (auth/admin) without
redesigning anything: one additive migration (`0014`), the same MVC
conventions, the same coding standards, the same AJAX-envelope pattern
every page already used. Everything below was run against a live
MySQL 8 database and a real PHP server during development.

## What was built

### Modules
| Module | Model | Controller | Notes |
|---|---|---|---|
| Companies | `CompanyModel` | `CompanyController` | Full CRUD, related leads/customers shown on detail page |
| Leads | `LeadModel` | `LeadController` | Table + Kanban pipeline, stage drag-and-drop, priority, owner |
| Customers | `CustomerModel` | `CustomerController` | Full CRUD, lifetime value tracking |
| Services | `ServiceModel` | `ServiceController` | Service types (appointment durations/prices) + service plans (recurring billing) |
| Lead Sources | `LeadSourceModel` | `LeadSourceController` | Simple admin CRUD with live lead counts |
| Appointments | `AppointmentModel` | `AppointmentController` | Day/week/month calendar, booking, recurrence |
| Tasks | `TaskModel` | `TaskController` | Attached to leads |
| Notes | `NoteModel` | `NoteController` | Polymorphic — attaches to lead/customer/company |
| Documents / Files | `AttachmentModel` | `AttachmentController` | One polymorphic table serves both names — see "Design decisions" below |
| Comments | `CommentModel` | `CommentController` | Polymorphic, threaded (`parent_id`) |
| Tags | `TagModel` | `TagController` | Global tag list + polymorphic attach/detach |
| Departments / Staff | *(Phase 2)* `DepartmentModel`/`UserModel` | *(Phase 2)* | Already complete — "Staff" is the Users list filtered by role/department, not a new table (see Phase 2 summary) |

### Lead Management
| Feature | Where |
|---|---|
| Business line scoping (Homecare / Cleaner / Both) | `leads.business_line_id`, nullable-both handled at the campaign/company level where "both" applies |
| Lead Status (Stage) | `lead_stages` table (Phase 1), Kanban drag-and-drop in `leads/index.php` |
| Lead Priority | New `leads.priority` enum (low/medium/high/urgent) |
| Owner | `leads.owner_id`, editable from the Lead Details sidebar |
| Pipeline | `LeadModel::pipelineGrouped()` — one query, grouped by stage, same filters as the table view |
| Communication Timeline | Reuses `ActivityLogger::timelineFor()` from Phase 1 |
| Appointment History | `AppointmentModel::historyFor('lead', $id)` |
| Customer Conversion | `LeadService::convertToCustomer()` — transactional, links both directions (`leads.converted_customer_id` and `customers.source_lead_id`) |
| Duplicate Detection | `LeadModel::findPotentialDuplicates()` — matches on email OR phone; the New Lead form warns and requires explicit "Create Anyway" confirmation |
| Merge Leads | `LeadService::merge()` — reassigns appointments, notes, comments, attachments, tags, tasks, and activity history to the survivor; the duplicate is soft-deleted and flagged `merged_into_lead_id`, never hard-deleted |

### Customer Management
Profile, Company Assignment (`customers.company_id`), Service History
(`AppointmentModel::historyFor('customer', ...)`), Communication
History (shared timeline), Documents (shared attachments panel),
Appointment History — all wired. **Invoices Ready**: the
`invoices`/`invoice_items`/`payments` schema already existed from
Phase 1 and `CustomerModel::recalculateLifetimeValue()` already reads
from it (called automatically when `AppointmentService::markCompleted()`
runs) — the Invoicing *UI* itself is explicitly scoped to Phase 4 per
the brief, and the customer detail page says so rather than pretending
it's built.

### Appointment System
| Feature | Where |
|---|---|
| Booking | `AppointmentController::store()` calling `AppointmentService::book()` |
| Calendar (Day/Week/Month) | One `/appointments/feed` JSON endpoint, client-side JS just varies the date range — verified live returning the correct row count for a date range spanning generated recurring occurrences |
| Availability | `AppointmentModel::hasConflict()` — checked before every booking AND every reschedule; verified live to correctly reject a double-booking attempt |
| Reminder | `appointments.reminder_minutes_before` / `reminder_sent_at`, `AppointmentModel::dueForReminder()` plus `AppointmentService::sendDueReminders()` — ready for a cron entry (not yet added as a bin script — see Phase 4 notes) |
| Reschedule | `AppointmentService::reschedule()` — re-checks conflict at the new time, clears `reminder_sent_at` so a fresh reminder goes out |
| Cancel | `AppointmentService::cancel()` with a required reason |
| Completed / Missed | `markCompleted()` (also triggers lifetime-value recalculation) and `markMissed()` |
| Recurring Appointments | `AppointmentService::generateOccurrences()` — real rows per occurrence, `parent_appointment_id` links them, capped at 52 generated occurrences regardless of `until` date |

## What was actually tested (not just written)

- **Duplicate detection**: created two leads sharing an email and
  phone, confirmed the second was flagged.
- **Merge**: attached a note and a task, confirmed after merging that
  both moved to the survivor lead, the duplicate's `merged_into_lead_id`
  was set, and it was soft-deleted (not hard-deleted).
- **Conversion**: converted a lead to a customer, confirmed the
  bidirectional link (`source_lead_id` on the customer,
  `converted_customer_id` back on the lead) — this caught a real bug
  (see below).
- **Recurring appointments**: booked a weekly series Aug 10 – Sep 1,
  2026; confirmed exactly 4 child occurrences were generated with the
  correct dates.
- **Conflict detection**: attempted to double-book the same staff
  member in an overlapping window; confirmed the booking was rejected.
- **CSV import**: a 3-row file (one valid, one with an invalid email,
  one duplicate of the valid row) produced exactly 1 created, 1
  skipped, 1 failed, with a correct per-row error log.
- **Full HTTP flow**: real login, create lead, view lead detail page,
  create company, create customer, book a recurring appointment,
  confirm the calendar feed returns the right count, export leads to
  CSV with correct formatting, soft-delete a lead, confirm it appears
  in `/leads/trashed`, restore it, confirm `deleted_at` cleared.

### Bugs caught by testing, not assumed away
1. **`converted_customer_id` silently not saving.** Missing from
   `LeadModel::$fillable`, so `BaseModel::update()`'s mass-assignment
   guard silently dropped it. Never would have been caught by reading
   the code; only showed up when the field was actually empty after a
   real conversion.
2. **Validator `max:30` rejecting a 10-digit phone number.** `min`/
   `max` compared numeric *magnitude* instead of string *length*
   whenever the value happened to look numeric (e.g. an all-digit
   phone number), because the original rule couldn't tell a `phone`
   field from an `estimated_value` field apart. Fixed by only treating
   a field as numeric-for-comparison-purposes when its own rule string
   explicitly says `numeric`/`integer`. This bug would have silently
   broken every phone number, postal code, or other numeric-looking
   string field validated with `max`/`min` anywhere in the app — caught
   by actually submitting a real phone number through the real HTTP
   flow, not by reading the Validator's code.

## Design decisions worth flagging explicitly

- **Documents and Files are the same table.** The brief lists them as
  separate modules; they were built as one `attachments` table and one
  `AttachmentController`, because splitting them would mean two
  identical schemas and two identical controllers with no behavioral
  difference — the frontend can still present a tab called "Documents"
  and a tab called "Files" against the same data if that distinction
  matters visually later.
- **"Staff" is not a new table.** Every staff member is already a
  `users` row (Phase 2). A dedicated shift-scheduling/availability
  concept belongs to a future scheduling-depth pass, not a duplicate
  of the user directory.
- **Comments vs. Notes** were kept as two distinct tables (not
  collapsed into one) because they have genuinely different shapes —
  notes are long-form and flat, comments are short and threaded
  (`parent_id`) — and the frontend already expects them as separate
  tabs.
- **Recurring appointments generate real rows, not virtual ones.**
  This was a deliberate choice over computing occurrences on the fly:
  it means any single occurrence can be rescheduled or cancelled
  independently (a caregiver calling in sick for one Tuesday shouldn't
  require special-casing "detach this occurrence from its series"
  logic), at the cost of a capped number of rows generated up front
  (52) rather than open-ended computation.

## Phase 4 readiness (Marketing)

Nothing here needs revisiting to build Marketing:

1. **Campaigns, email templates, and automation workflows** already
   have their full schema from Phase 1 (`campaigns`,
   `campaign_recipients`, `email_templates`, `automation_workflows`,
   `automation_steps`, `automation_runs`) — completely untouched by
   Phases 2/3, ready for a `CampaignController`/`CampaignService`
   following the exact same pattern as `LeadController`/`LeadService`.
2. **Tags** (this phase) are immediately reusable for marketing
   segmentation — a campaign's audience filter can already query
   `taggables` to target "leads tagged VIP," no new table needed.
3. **The `import_batches` pattern** (this phase) generalizes directly
   to a marketing contact-list import without any schema change — just
   a new `entity_type` value.
4. **`ExportService`** is already structured as one method per entity;
   adding `exportCampaignRecipients()` follows the exact same `toCsv()`
   call shape as the four methods already there.
5. **Appointment reminders** (`AppointmentModel::dueForReminder()` and
   `AppointmentService::sendDueReminders()`) are built but not yet
   wired to a cron entry — Phase 4 (or an infra pass before it) should
   add a `bin/send-reminders.php` CLI script calling
   `sendDueReminders()` on a schedule, following the same pattern as
   `bin/migrate.php`/`bin/seed.php`.
