# Support Chat: Order Linking via Polymorphic Relation

How a support `Conversation` optionally attaches to an `Order` (or, in future,
another entity) using a nullable polymorphic (`morphTo`) relation, instead of
a dedicated `order_id` foreign key. This is a developer-facing design note —
for the API request/response shapes, see
[docs/api/06-shared-general.md](../api/06-shared-general.md) §03 (client/
company/technician "own conversation" endpoints) and
[docs/api/05-admin.md](../api/05-admin.md) §13 (admin oversight endpoints).

## Order Support vs. General Support

There are exactly two kinds of conversation, and which one you get is decided
entirely by whether `entity_id` is present on the create request — there is
no separate "type" field to set:

- **`entity_id` sent** → **order support**. `entity_type` is never accepted
  from the client and is not part of the request at all; the server sets it
  to `Order::class` by default, unconditionally, whenever `entity_id` is
  present. The id must resolve to an order owned by the requesting user
  (`findOrFail`, scoped to `customer_id`) or the request 404s.
- **`entity_id` omitted (or null)** → **general support**. Both `entity_type`
  and `entity_id` are left `null` on the conversation — it isn't "about"
  anything.

```php
// StoreConversationRequest — the entire request surface for linking:
'entity_id' => ['nullable', 'integer'],
```

Sending `entity_id` is both necessary and sufficient to get an order-linked
conversation; there's nothing else to pass and nothing else that changes the
outcome. See [Create flow](#create-flow--conversationservicestore) for the
resolution code.

## Why polymorphic instead of `order_id`

Most conversations are general support and aren't about anything specific.
The ones that are can currently only be about an `Order`, but the schema
doesn't hard-code that — `entity_type` + `entity_id` follow the exact same
convention already established by [`InternalNote`](../../app/Models/InternalNote.php)
for staff annotations on Orders/Contracts/Users. Reusing that convention means:

- Adding a second linkable entity later (e.g. a Contract-linked conversation)
  is a model/config change, not a migration + new nullable FK + new filter
  branch.
- One relation (`entity()`) and one query pattern to reason about across both
  `Conversation` and `InternalNote`, instead of a bespoke shape per entity.

The trade-off: DB-level referential integrity is gone (no FK constraint on
`entity_id`), and every entity type in `ENTITY_MAP` needs its own ownership
check when resolving — see [Ownership enforcement](#ownership-enforcement-on-create).

## Schema

`entity_type` / `entity_id` were added to the already-existing `conversations`
table by a dedicated migration, not baked into the original create:

```php
// database/migrations/2026_08_23_090000_add_entity_columns_to_conversations_table.php
Schema::table('conversations', function (Blueprint $table) {
    $table->nullableMorphs('entity', 'conversations_entity_index');
});
```

`nullableMorphs()` adds `entity_type` (string, nullable) and `entity_id`
(unsigned big int, nullable) plus a named composite index. Nullable because
most conversations are general support and are never "about" anything.

## Model side — [`app/Models/Conversation.php`](../../app/Models/Conversation.php)

```php
public const ENTITY_MAP = [
    'order' => Order::class,
];

public function entity(): MorphTo
{
    return $this->morphTo('entity', 'entity_type', 'entity_id');
}

public function entityAlias(): ?string
{
    return array_search($this->entity_type, self::ENTITY_MAP, true) ?: null;
}

public function scopeEntityType($query, string $alias): void
{
    $fqcn = self::ENTITY_MAP[$alias] ?? $alias;
    $query->where('entity_type', $fqcn);
}

public function scopeEntityId($query, int $id): void
{
    $query->where('entity_id', $id);
}
```

`ENTITY_MAP` is the single source of truth for both directions:

| Direction | Used by |
|---|---|
| alias → FQCN | `resolveOwnEntity()` on create, `scopeEntityType()` on filtering |
| FQCN → alias | `entityAlias()`, so API responses expose `"order"`, never the raw class string |

`entity_type` stores the full class-string (`App\Models\Order`) in the
database — same as `InternalNote` — but **clients never see the FQCN**, only
the alias. `Order` is currently the only entry; adding a second one is:
add the map entry and (if the new entity isn't customer-owned the same way)
extend `resolveOwnEntity()` — see below, since on create `entity_type` is no
longer client input.

## Create flow — [`ConversationService::store()`](../../app/Services/Shared/ConversationService.php)

Shared across client/company/technician (each hits its own route, same
service). Implements the [Order Support vs. General Support](#order-support-vs-general-support)
rule above: only `entity_id` is accepted from the request, and
`resolveOwnEntity()` below is where `entity_type` actually gets set to
`Order::class` server-side.

```php
public function store(Request $request): JsonResponse
{
    $entity = $this->resolveOwnEntity($request);

    $conversation = Conversation::create([
        'customer_id' => auth('api')->id(),
        'subject'     => $request->input('subject'),
        'entity_type' => $entity?->getMorphClass(),
        'entity_id'   => $entity?->getKey(),
        'status'      => ConversationStatus::OPEN,
        'created_at'  => now(),
    ]);
    // ... opening Message created in the same DB transaction
}
```

The resolved `$entity` model — not the raw request input — is what gets
written to `entity_type`/`entity_id`. `getMorphClass()` returns the FQCN
Eloquent uses for polymorphic type columns (respects `Relation::morphMap()`
if one is ever configured); `getKey()` is the primary key. If no `entity_id`
was sent, both columns stay `null`.

### Ownership enforcement on create

```php
private function resolveOwnEntity(Request $request): ?Model
{
    $entityId = $request->input('entity_id');

    if ($entityId === null) {
        return null;
    }

    $modelClass = Conversation::ENTITY_MAP['order'];

    // Assumes a customer_id column — fine while Order is the only entry;
    // a future non-customer-owned entity type needs its own ownership check.
    return $modelClass::where('customer_id', auth('api')->id())
        ->findOrFail($entityId);
}
```

This is the load-bearing ownership check: a customer can link a conversation
only to **their own** order (`findOrFail` 404s otherwise) — the request
can't graft a conversation onto someone else's order by guessing an id. The
`customer_id` assumption is called out in the source comment because it's
specific to `Order`; it is not something `morphTo` gives you for free, and
it's the part that needs rewriting (along with hard-coding a different
`ENTITY_MAP` key here) if a second entity type is ever added.

Validation (`StoreConversationRequest`) only constrains `entity_id`'s type —
there is no `entity_type` field in the request at all anymore:

```php
'entity_id' => ['nullable', 'integer'],
```

## Filtering — list endpoints

Both the shared (own-conversations) and admin (all-conversations) `index()`
apply the same two scopes when the corresponding filter is present:

```php
if ($entityType = $request->input('filters.entity_type')) {
    $query->entityType($entityType);   // alias -> FQCN via scope
}
if ($entityId = $request->input('filters.entity_id')) {
    $query->entityId((int) $entityId);
}
```

i.e. `GET /client/conversations?filters[entity_type]=order&filters[entity_id]=42`
returns only conversations about that specific order. Admin's version has no
`customer_id` scoping, so it can pull every conversation about a given order
regardless of who opened it.

## Reading the linked entity back

`entity()` is a standard `MorphTo` — eager-load and read it like any other
relation:

```php
$conversation->load('entity');   // resolves to an Order instance, or null
$conversation->entityAlias();    // "order", or null
```

Resources expose the alias (never the FQCN) alongside `entity_id` — check
`ConversationDetailsResource` (shared) / `ConversationResource` (admin) for
the exact response field names, since those are the layer that's
generated/verified against live responses (see docs/api/README.md).

## Precedent: `InternalNote`

`Conversation`'s `entity_type`/`entity_id` deliberately mirror
[`InternalNote`](../../app/Models/InternalNote.php), which already uses the
same polymorphic pair for staff-only notes on Order/Contract/User. Differences
worth knowing if you're cross-referencing the two:

| | `Conversation.entity` | `InternalNote.entity` |
|---|---|---|
| Nullable | Yes — most conversations aren't about anything | No — every note is about something |
| Allowed types today | `Order` only (`ENTITY_MAP`) | `Order`, `Contract`, `User` |
| Alias translation | Yes (`ENTITY_MAP` + `entityAlias()`) — API never sees the FQCN | No — `InternalNote` isn't customer-facing, so nothing hides the FQCN from callers |
| Visibility | Customer + staff (it's the chat) | Staff-only, never customer-visible |

`InternalNote` doesn't need alias translation because only admins ever see
it; `Conversation` does, because the alias is what client/company/technician
apps send and receive over the API.
