# TechTitans POS — Customer CRM Phase C8 Camera & Profile Photo

**Date:** 2026-11-12 · **Branch:** `techtitans/integration` (backend + frontend) · Local commits only (no push/deploy).
**Scope:** an optional, secure customer profile photo (capture / upload / crop / save / replace / remove), reusing the existing photo toolkit. **No biometric identification** is implemented — see `CUSTOMER-BIOMETRIC-VERIFICATION-FEASIBILITY.md`. No NH POS / legacy PHP touched; no historical data imported; nothing pushed. The legacy portal's unsafe `move_uploaded_file` upload code (confirmed RCE in `customer-legacy/02`) is **not** reused.

## 1. Reused existing infrastructure (Phase 1 audit)
This increment deliberately mirrors the production-proven **employee photo** feature rather than inventing anything:
- **`EmployeePhotoService`** → new **`CustomerPhotoService`** (GD content-validation, square 512×512 JPEG re-encode stripping EXIF, private disk, tenant-scoped UUID path, new-before-old replace, audit with presence-only booleans).
- **Signed-URL delivery** (`employees.photo`) → new **`customers.photo`** route (`signed` middleware): the `<img>` loads a temporary signed URL — no bearer header, raw path never exposed, guessed ids can't produce a valid URL.
- **`CameraModal`** (stream requested only on open; every track stopped on close/unmount; permission-denied / no-camera messages) — reused, and **additively extended** with a multi-camera picker (enumerate `videoinput` devices after permission, switch `deviceId`).
- **`ImageCropper`** (round-mask avatar crop + zoom → 512×512 JPEG blob) — reused.
- **`Avatar`** — additively extended with an optional `src` (photo) that falls back to initials.

## 2. Backend (additive, secure)
- **Migration** `2026_11_12_150000_add_photo_to_customers_table` — nullable `photo_path` + `photo_consent_at` on `customers`. **No bytes in the DB**, no new tables.
- **`CustomerPhotoService`**: validates by ACTUAL content (`getimagesizefromstring` → only JPEG/PNG/WEBP), decodes + centre-crops + re-encodes to 512×512 JPEG with GD (strips metadata; SVG/HTML/scripts/polyglots fail the content check and never survive). Stores under `customer-photos/{tenant}/{uuid}.jpg` on the **private `local`** disk (`storage/app/private`). Replace writes new-before-old; remove/replace delete the old file only after a successful commit; a DB/audit failure removes the orphan. Audit events carry only presence booleans + the fixed output shape — never filename/path/bytes/EXIF.
- **Endpoints** (`CustomerController`): `POST /customers/{id}/photo` (multipart, **customers.manage**), `DELETE /customers/{id}/photo` (**customers.manage**), `GET /customers/{id}/photo` (**signed**, streams `image/jpeg`, `Cache-Control: private`). `UploadCustomerPhotoRequest` first-pass validates `image|mimes:jpeg,jpg,png,webp|max:5120` (5 MB) + an optional `consent` boolean.
- **`CustomerResource::one`** gains `has_photo`, a fresh **30-min signed** `photo_url`, and `photo_consent_at` — so **every** customer projection (list, profile, POS/Repairs/IPTV search, directory) exposes the avatar from the **one** endpoint (no per-module copies).

## 3. Frontend
- **`CustomerPhotoDrawer`** (right-side `FormDrawer`): circular preview, **Upload existing** / **Take photo** (→ `CameraModal`) / **Remove**, a crop step (`ImageCropper` → 512 JPEG blob), a **consent** checkbox + privacy copy, and **Save**. The camera stream is requested only after "Take photo" and every track is stopped on close/capture/unmount; permission-denied / no-camera show clear messages while Upload stays usable. Oversized/unsupported files are rejected client-side (5 MB; JPG/PNG/WEBP) and again server-side.
- **Camera badge** on the profile avatar (customers.manage) opens the drawer; the avatar (header + Customers list, and anywhere the shared `Avatar` is used) renders the signed photo with an initials fallback.
- **`CameraModal`** multi-camera picker (shown only when >1 `videoinput`) — additive; existing employee/repair callers are unaffected.

## 4. Consent & privacy (Phase 5)
The photo is **optional**. The drawer explains it is used **only** to recognise the customer at the counter, stored privately, and is **NOT used for facial recognition or any biometric matching**; the customer can have it removed any time. A consent checkbox stamps `photo_consent_at` (operational-photo consent only — *not* consent to biometric processing); removing the photo clears it. **No face embeddings/templates are extracted, stored or compared; bystanders are not processed.** Biometric verification is deferred to a separately-approved design (feasibility doc).

## 5. Security checks (verified by tests)
- Content-type validation by real bytes (a `shell.jpg` containing PHP is **rejected 422**, nothing stored).
- Safe re-encode to 512×512 JPEG (metadata stripped) — asserted on the stored file.
- Random UUID filenames under a **tenant-scoped private** folder; DB holds only the path.
- Authenticated/**signed** retrieval — an unsigned URL is **403**.
- Replacement deletes the old file (new-before-old); removal deletes the file + clears flags.
- **Tenant isolation** (cross-tenant upload → 404) and **permission** gating (view-only → 403).
- Camera tracks released on close (CameraModal lifecycle, reused + unit-tested).

## 6. Testing
- **Backend** `CustomerPhotoTest` → **6 passed** (validate+re-encode+signed-URL, replace removes old file, remove clears flags, spoofed-file rejection, requires customers.manage, tenant isolation). Full `--filter=Customer` → **118 passed**. **Pint + PHPStan clean**.
- **Frontend** `CustomerPhotoDrawer.test.tsx` → **2 passed** (upload→crop→consent-gated save calls `uploadPhoto(id, Blob, true)`; remove calls `removePhoto`; privacy copy rules out biometrics). No regressions across media/employee/repair/customer suites (**160 passed**). `tsc`/`eslint`/`npm run build` clean.
- **Live Playwright** (fake camera device): camera badge present; drawer opens with Upload/Take/consent + the no-biometric note; **Take photo → live camera → Capture → Use photo → ImageCropper opens** (full capture pipeline); the avatar renders the stored photo via the signed URL; **no console errors**; **no horizontal overflow** at 1920/1440/768/390 and 125/150/200 % zoom. Screenshots in `screenshots/customer-crm-c8/`.

## 7. Returning-customer workflow (Phase 7)
The C2 search (name / phone / customer number / email / IPTV MC ID where authorized) already returns customers with the signed `photo_url`; results render the avatar so staff can **visually confirm** the right record before opening it. Staff interaction is always required — **no automatic face-based identification from a live feed** (that is Phase 8, deferred).

## 8. Remaining limitations
- **Rotation** in the editor: `ImageCropper` provides crop + zoom (round avatar); a rotate control is a possible future add.
- Avatar wiring is applied to the **profile + Customers list**; other lookup surfaces (POS/Repairs/IPTV) inherit it automatically wherever they render the shared `Avatar` with the customer's `photo_url` (a one-line prop) — remaining call-sites can adopt it incrementally.
- **No biometric features** by design (see feasibility doc); retention beyond "delete on request" (e.g. auto-expiry) is a future policy decision.

## 9. Commits (local only — no push)
- **Backend:** this commit — migration + `CustomerPhotoService` + controller/resource/routes + `CustomerPhotoTest` + this report + screenshots.
- **Frontend:** `388e37b` — `CustomerPhotoDrawer`, `Avatar` src, camera badge + list wiring, `CameraModal` multi-camera, API + types + tests.
