# Property Concierge (Main · User Portal)

**Portal:** Main · **Routes:** `main.portal.concierge.*` (owner) · **Nav:** **the Dashboard's Property Concierge widget** — it has NO sidebar entry (2026-08-26: a member asks for a service a few times a year, which does not earn a permanent nav slot, but it is worth offering while they are looking at what they own). Because of that, all three pages carry an explicit "Back to dashboard" link, per GUIDELINES §15 · **Gated by:** `['auth','main','contact.verified']`

> ⚠️ **The agent desk is not in this portal any more.** The customer-portal "Concierge Desk"
> (`main.portal.concierge.agent.*`, `ConciergeAgentController`, `Pages/Main/Portal/Concierge/Agent/`)
> was removed — admins work requests in the **Manage** portal instead: reads on
> `manage.portal.concierge.{index,show}` (Portal Engagement → Property Concierge) and writes on
> `manage.concierge.requests.*` (`['auth','admin']` + `view-concierge` / `manage-concierge`).
> Staff had no reason to sign into the member portal to do back-office work, and the desk needed
> the Manage list/permission kit. See
> [Portal Engagement](/docs/modules_handbook/manage/portal-engagement/readMe.md).

## What it does
A member asks InvestHink to handle a service on one of their properties — **list for sale, rent
out, renovation, or management** (multi-select). They fill a wizard with the property's details,
attach photos, and submit. Admins act as the **concierge desk**: they see an inbox of all requests,
**claim** one, **advance** it through the workflow, post replies, or cancel it. Owner and desk
share one timeline per request, so each side sees status changes and the other's messages.

> A request belongs to the **lead** (lead == portal user — see
> [Users · Leads · Admins · Login](/docs/modules_handbook/shared/user-lead-admin-login-register-merge/readMe.md)),
> so owner queries key on `lead_id`. The assigned agent is a `User` (admin) on `assigned_admin_id`.

## How it works
- **Lifecycle.** `draft → submitted → claimed → in_progress → completed`, plus `withdrawn` (owner
  exit) / `cancelled` (agent) from any non-terminal state. Statuses, services, property types,
  furnishing and tenure are all `const` arrays on `ConciergeRequest` (`STATUSES`, `SERVICES`,
  `TYPES`, …) — never hardcoded in the UI. `AGENT_NEXT` is the agent's forward-progress map and
  `TERMINAL_STATUSES` blocks further transitions.
- **Create + edit (owner).** The wizard `create()`/`store()` builds a **draft** via
  `ConciergeRequestRepository::create()` — it assigns a per-year human ref (`CR-YYYY-NNN` via
  `generateRef()`), sets `STATUS_DRAFT`, and logs the first timeline event. `update()` only touches
  the owner-editable property/intent fields and is refused once the request leaves draft. All writes
  go through the repository (each in `DB::transaction`, returning the fresh model, per GUIDELINES).
- **Photos.** `attachPhotos()` validates each upload is an image, stores it on the **`public`
  disk** under `concierge/{uuid}/photos/`, and rows it into `concierge_request_photos`
  (`sort_order`, first image becomes `is_cover`). `ConciergeRequestPhoto::url` resolves the public
  URL (needs `php artisan storage:link`). Photos soft-cascade with the request (`$softCascade`).
  *(Unlike Analyze Property, this module writes the public disk directly rather than through
  [MediaService](/docs/modules_handbook/shared/media/readMe.md) — see that doc for the GCS-backed,
  signed-URL pattern the rest of the app uses.)*
- **Timeline (the shared log).** Every status change and every owner/agent message is an immutable
  `ConciergeRequestEvent` (`TYPE_STATUS_CHANGE` / `_OWNER_MESSAGE` / `_AGENT_MESSAGE`, with
  `from_status`/`to_status` and `actor_role`). `logEvent()` appends; the repo's status methods
  (`submit`, `withdraw`, `claim`, `advance`, `cancel`) all funnel through `changeStatus()`, which
  saves the status + `last_active_at` and logs the change in one transaction. Events are never
  soft-deleted.
- **Owner controller** (`ConciergeController`) — `index` (card grid of the member's own requests),
  `show` (detail + timeline), `store`/`update`, and the actions `submit` / `withdraw` / `message`.
  Every request is re-scoped to the member via `ownedOrFail()`.
- **Agent desk (Manage portal).** Reads live on `Manage\Portal\ConciergeController` — `index` is a
  §14 DataTable of every member's request (owning lead as a column, `ConciergeQueryRequest` +
  `ResolvesListQuery`), `show` is the detail + timeline + back-office tabs. It **only reads**; every
  write posts to `Manage\Concierge\ConciergeRequestsController` under `manage.concierge.requests.*`
  (`claim`, `advance`, `cancel`, `message`, plus `marketing` / `viewings` / `offers` / `close` /
  `notes`), gated by `manage-concierge` — the read pages surface those actions disabled when the
  admin only holds `view-concierge`. Both sides call the same
  `ConciergeRequestRepository`, so the lifecycle rules are identical wherever the write comes from.
  *(This replaced the old in-portal `ConciergeAgentController` inbox.)*
- **Agent notes are never shown to the owner.** `ConciergeRequestEvent::OWNER_HIDDEN_TYPES`
  (`TYPE_AGENT_NOTE`) is filtered out of the owner timeline; the desk's `notes` prop is only sent
  when the admin holds `manage-concierge`.

## Related files

**Backend — Models**
- [src/Concierge/ConciergeRequest.php](/src/Concierge/ConciergeRequest.php) — the request; lifecycle/service/type constants, `generateRef()`, `isTerminal()`, `lead`/`agent`/`owner`/`events`/`photos`/`coverPhoto` relations, photo soft-cascade.
- [src/Concierge/ConciergeRequestEvent.php](/src/Concierge/ConciergeRequestEvent.php) — one timeline entry (status change / message); `TYPE_*` + `ROLE_*` constants.
- [src/Concierge/ConciergeRequestPhoto.php](/src/Concierge/ConciergeRequestPhoto.php) — an uploaded image; `url` accessor (public disk).

**Backend — Repository**
- [src/Concierge/Repositories/ConciergeRequestRepository.php](/src/Concierge/Repositories/ConciergeRequestRepository.php) — all writes: `create` / `update` / `submit` / `withdraw` / `claim` / `advance` / `cancel` / `changeStatus` / `postMessage` / `attachPhotos` / `logEvent`.

**Backend — Controllers & Form Requests**
- [app/Http/Controllers/Main/Portal/ConciergeController.php](/app/Http/Controllers/Main/Portal/ConciergeController.php) — owner side (index/create/show/store/update/submit/withdraw/message).
- [app/Http/Controllers/Manage/Portal/ConciergeController.php](/app/Http/Controllers/Manage/Portal/ConciergeController.php) — the desk's **reads** (index DataTable + show), in the Manage portal.
- [app/Http/Controllers/Manage/Concierge/ConciergeRequestsController.php](/app/Http/Controllers/Manage/Concierge/ConciergeRequestsController.php) — the desk's **writes** (claim/advance/cancel/message + marketing/viewings/offers/close/notes).
- [app/Http/Controllers/Concerns/PresentsConciergeRequest.php](/app/Http/Controllers/Concerns/PresentsConciergeRequest.php) — the shared shapers both portals render from (owner side never gets agent notes).
- Owner form requests: [StoreRequest.php](/app/Http/Requests/Main/Portal/Concierge/StoreRequest.php) · [UpdateRequest.php](/app/Http/Requests/Main/Portal/Concierge/UpdateRequest.php) · [MessageRequest.php](/app/Http/Requests/Main/Portal/Concierge/MessageRequest.php)
- Desk form requests: [Manage/Portal/ConciergeQueryRequest.php](/app/Http/Requests/Manage/Portal/ConciergeQueryRequest.php) · [Manage/Concierge/](/app/Http/Requests/Manage/Concierge/) (`MessageRequest`, `NoteRequest`, `MarketingPostRequest`, `ViewingRequest`, `OfferRequest`, `CloseDealRequest`)

**Frontend (Vue)**
- Owner: [Index.vue](/resources/js/Pages/Main/Portal/Concierge/Index.vue) (card grid) · [Create.vue](/resources/js/Pages/Main/Portal/Concierge/Create.vue) (wizard) · [Show.vue](/resources/js/Pages/Main/Portal/Concierge/Show.vue) (detail + timeline).
- Owner partials: [PhotoUploader.vue](/resources/js/Pages/Main/Portal/Concierge/Partials/PhotoUploader.vue) · [RequestCard.vue](/resources/js/Pages/Main/Portal/Concierge/Partials/RequestCard.vue) · [StatusBadge.vue](/resources/js/Pages/Main/Portal/Concierge/Partials/StatusBadge.vue) · [Timeline.vue](/resources/js/Pages/Main/Portal/Concierge/Partials/Timeline.vue).
- Desk (Manage portal): [Manage/Portal/Concierge/Index.vue](/resources/js/Pages/Manage/Portal/Concierge/Index.vue) (DataTable) · [Show.vue](/resources/js/Pages/Manage/Portal/Concierge/Show.vue) + [Partials/Tabs/](/resources/js/Pages/Manage/Portal/Concierge/Partials/Tabs/) (`MarketingTab`, `ViewingsTab`, `OffersTab`, `ClosingTab`, `NotesTab`). There is no `Pages/Main/Portal/Concierge/Agent/` folder any more.

**Migrations**
- [database/migrations/2026_06_04_000001_create_concierge_requests_table.php](/database/migrations/2026_06_04_000001_create_concierge_requests_table.php)
- [database/migrations/2026_06_04_000002_create_concierge_request_events_table.php](/database/migrations/2026_06_04_000002_create_concierge_request_events_table.php)
- [database/migrations/2026_06_04_000003_create_concierge_request_photos_table.php](/database/migrations/2026_06_04_000003_create_concierge_request_photos_table.php)
- [database/migrations/2026_06_13_000003_add_lead_id_to_concierge_requests_table.php](/database/migrations/2026_06_13_000003_add_lead_id_to_concierge_requests_table.php) — re-key ownership from user to lead.

**Seeders**
- None. (No `Facades/` either — the repository is constructor-injected, not facade-resolved.)

**Routes**
- [routes/main.php](/routes/main.php) — `main.portal.concierge.*` (owner, `/property-concierge`, inside the `['auth','main','contact.verified']` group). The file keeps a comment where the removed `/concierge-desk` group used to be.
- [routes/web.php](/routes/web.php) — the desk: `manage.portal.concierge.{index,show}` (in the `portal-engagement` prefix) and the write group `manage.concierge.requests.*` (`/manage/concierge-requests`, `view-concierge` + `manage-concierge`).

**See also:** [Media](/docs/modules_handbook/shared/media/readMe.md) (the canonical upload service — Concierge currently stores photos on the public disk directly rather than through it).
