# JSON API v1

API для мобильного приложения. Поверх тех же сервисов, что и сайт: цена
считается тем же `PriceResolver`, заказ кладёт тот же `OrderWriter`, поиск идёт
тем же `SearchEngine`. Отдельная «логика для приложения» стала бы вторым
источником правды и разошлась бы с сайтом в первый же месяц.

## Адрес и версия

`https://{домен сайта}/api/v1/…`

**Арендатор определяется доменом**, как и на сайте: у приложения адрес API —
это адрес его сайта, и никакого «выбора сайта» в протоколе нет.

**Версия в пути, а не в заголовке.** Приложение живёт на телефоне годами,
и «включите нам старое поведение заголовком» — это переписка с владельцем
сайта вместо строки в коде.

## Форма ответа

```json
{ "data": … , "meta": { "page": 1, "total": 42 } }
{ "message": "Товар не найден." }
{ "message": "…", "errors": { "phone": ["…"] } }
```

Данные всегда в `data`. Конверт нужен затем, чтобы добавить `meta` без ломки
клиента.

## Вход — тем же звонком, что и на сайте

```
POST /api/v1/auth/phone/start   { phone }        → { masked, expires_in }
POST /api/v1/auth/phone/verify  { phone, code }  → { token, user }
GET  /api/v1/auth/me
POST /api/v1/auth/logout
```

Отдельного пароля для приложения нет намеренно: он завёл бы второй способ
входа в ту же учётку, а значит и второй способ её потерять.

В ответе на `start` полного номера нет: приложение его и так знает, а журнал
прокси между ним и сервером — не обязан.

`logout` гасит **только текущий токен**: у покупателя может быть планшет,
и выход с телефона не должен выкидывать его отовсюду.

## Токен помечен сайтом

⚠️ `users.id` и значение токена сквозные по установке. Без метки сайта токен,
выданный на домене одного арендатора, **поднял бы покупателя на домене
другого**.

Поэтому:

- у `personal_access_tokens` есть `site_id` (и таблица объявлена `tenant`,
  иначе выгрузка сайта увезла бы токены всех арендаторов);
- метку ставит сама модель токена при создании — забыть нельзя;
- токен чужого сайта **не находится вовсе**: проверка стоит в `findToken()`,
  а не в гварде, который можно забыть подключить к маршруту.

⚠️ **Сессия сайта к API отношения не имеет.** Штатное `sanctum.guard => ['web']`
заставляет Sanctum сперва спросить сессионный гвард: посетитель, вошедший
на сайте, получал бы доступ к API того же домена вообще без токена. У нас
там пусто.

⚠️ **Гость получает 401, а не редирект.** Штатное поведение `Authenticate` —
увести на маршрут `login`, которого у нас нет вовсе (вход живёт блоком
в шапке), и клиент без заголовка `Accept: application/json` получал 500.
Нашлось живым прогоном.

## Гвард по умолчанию переключается на токен

`UseApiGuard` в группе маршрутов делает `Auth::shouldUse('api')`. Одна строка
вместо правок в десяти классах: корзина, резолвер цен и группы покупателя
спрашивают текущего пользователя у гварда по умолчанию, и без переключения
приложение с токеном получало бы цены гостя и ничью корзину — причём молча.

## Эндпоинты

| Метод | Адрес | Токен | Что |
|---|---|---|---|
| GET | `/site` | — | название, дерево разделов, города |
| GET | `/search?q=` | — | поиск; `&suggest=1` — подсказки |
| GET | `/sections/{id}/products` | — | товары раздела |
| GET | `/products/{id}` | — | карточка с характеристиками и остатками |
| GET | `/cart` | да | корзина |
| POST | `/cart` | да | добавить `{product_id, variant_id?, quantity?}` |
| PUT | `/cart/{item}` | да | количество; `0` удаляет |
| DELETE | `/cart/{item}` | да | убрать позицию |
| GET | `/delivery` | да | способы доставки |
| GET | `/orders`, `/orders/{id}` | да | свои заказы |
| POST | `/orders` | да | оформить из корзины |

Витрина открыта без токена — она и на сайте открыта. Цена при этом всё равно
персональная, если запрос пришёл с токеном.

## Корзина приложения — это корзина сайта

Она лежит в базе и привязана к покупателю, поэтому собранная в приложении
находится на сайте и наоборот. Ради этого корзину и не стали держать в сессии.

Гостевой корзины в API нет: у приложения нет куки, а выдавать ему гостевой
токен корзины значило бы заводить второй механизм опознания рядом
с уже существующим токеном доступа.

## Заказ

Телефон берётся **из учётки**, а не из запроса: он подтверждён звонком
при входе, и подменять его на произвольный при оформлении значило бы
обесценить подтверждение. Стоимость доставки, как и на сайте, считает сервер
и из запроса не принимает.

У заказа проставляется `source = app` — тот же писатель, что у сайта,
отличается только источником.

## Что проверено живым прогоном

Заказ звонка → код → токен → `/auth/me` → товар → корзина с персональной ценой
→ токен на домене другого сайта отвечает 401.

## Чего ещё нет

| Что | Когда |
|---|---|
| Оформление с оплатой онлайн из приложения | вместе с мобильным клиентом |
| Отправка форм через API | по мере надобности |
| Push-уведомления о статусе заказа | вместе с приложением |
| Ограничение частоты запросов | перед первым публичным клиентом |
| OpenAPI-схема | когда появится сторонний потребитель |
