Compare commits
1 Commits
62475c7e48
...
c52171c4a8
| Author | SHA1 | Date |
|---|---|---|
|
|
c52171c4a8 |
|
|
@ -7,4 +7,3 @@ dist
|
||||||
.worktrees
|
.worktrees
|
||||||
.superpowers
|
.superpowers
|
||||||
.ruff_cache
|
.ruff_cache
|
||||||
volumes/db/data/
|
|
||||||
|
|
|
||||||
27
README.md
27
README.md
|
|
@ -9,26 +9,9 @@ npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## Деплой
|
|
||||||
|
|
||||||
Приложение разворачивается через Docker (`docker-compose.app.yml`). Сборка и запуск:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.app.yml up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
### Автодеплой
|
|
||||||
|
|
||||||
Настроен webhook в Gitea: при пуше в `main` автоматически запускается деплой.
|
|
||||||
|
|
||||||
- **Webhook listener**: systemd-сервис `supersam-webhook` на порту 9000
|
|
||||||
- **Gitea hook**: push в `main` → `POST http://10.0.2.1:9000/webhook/supersam`
|
|
||||||
- **deploy.sh**: `git pull origin main` → `docker compose up -d --build`
|
|
||||||
- Репозиторий: `https://git.supersamsev.ru/mihail/supersam`
|
|
||||||
|
|
||||||
## Главный документ
|
## Главный документ
|
||||||
|
|
||||||
- [Обзор системы](docs/product-overview.md) — назначение приложения, роли, сценарии, клиентский flow и подготовка к показу.
|
- [Обзор системы](/Users/mihailkucer/Documents/super-sam/docs/product-overview.md) — назначение приложения, роли, сценарии, клиентский flow и подготовка к показу.
|
||||||
|
|
||||||
## Что уже есть
|
## Что уже есть
|
||||||
|
|
||||||
|
|
@ -36,23 +19,15 @@ docker compose -f docker-compose.app.yml up -d --build
|
||||||
- Role-based dashboard для менеджера, логиста и водителя.
|
- Role-based dashboard для менеджера, логиста и водителя.
|
||||||
- Карточка заказа с составом, комментариями и историей.
|
- Карточка заказа с составом, комментариями и историей.
|
||||||
- Публичная страница `/delivery/:token` для выбора даты, половины дня и просмотра состава заказа.
|
- Публичная страница `/delivery/:token` для выбора даты, половины дня и просмотра состава заказа.
|
||||||
- **Самовывоз** — вкладки Доставка/Самовывоз на клиентской странице, выбор даты и половины дня самовывоза, информация о бесплатном хранении (2 рабочих дня) и платном (300₽/день).
|
|
||||||
- Supabase SQL-схема, таблицы приглашений и Edge Functions для invitation flow.
|
- Supabase SQL-схема, таблицы приглашений и Edge Functions для invitation flow.
|
||||||
- Документация по продукту, архитектуре и сценариям.
|
- Документация по продукту, архитектуре и сценариям.
|
||||||
|
|
||||||
## Структура
|
## Структура
|
||||||
|
|
||||||
- `src/` — интерфейс и клиентская логика.
|
- `src/` — интерфейс и клиентская логика.
|
||||||
- `src/constants/deliveryWorkflow.js` — статусы доставки, включая самовывоз.
|
|
||||||
- `src/components/client/DeliverySlotsPicker.jsx` — виджет выбора слотов доставки.
|
|
||||||
- `src/components/client/PickupSlotsPicker.jsx` — виджет выбора слотов самовывоза.
|
|
||||||
- `src/components/client/DeliveryChoiceFlow.jsx` — флоу согласования доставки/самовывоза.
|
|
||||||
- `src/pages/ClientDeliveryPage.jsx` — публичная страница с вкладками Доставка/Самовывоз.
|
|
||||||
- `src/components/orders/OrderDetailPanel.jsx` — карточка заказа с управлением доставкой и самовывозом.
|
|
||||||
- `supabase/schema.sql` — структура БД, роли, индексы, RLS, триггеры.
|
- `supabase/schema.sql` — структура БД, роли, индексы, RLS, триггеры.
|
||||||
- `supabase/functions/` — Edge Functions для приглашений, статусов и чат-коммуникаций.
|
- `supabase/functions/` — Edge Functions для приглашений, статусов и чат-коммуникаций.
|
||||||
- `supabase/seed/stage-1-demo.sql` — набор seed-данных для показа заказчику.
|
- `supabase/seed/stage-1-demo.sql` — набор seed-данных для показа заказчику.
|
||||||
- `docs/architecture.md` — архитектура фронтенда и модулей.
|
- `docs/architecture.md` — архитектура фронтенда и модулей.
|
||||||
- `docs/product-overview.md` — общий обзор продукта, ролей и сценариев.
|
- `docs/product-overview.md` — общий обзор продукта, ролей и сценариев.
|
||||||
- `docs/scenarios.md` — сценарии жизненного цикла заказа.
|
- `docs/scenarios.md` — сценарии жизненного цикла заказа.
|
||||||
- `docs/n8n-order-group-delivery-flow.md` — потоки n8n для оркестрации доставки.
|
|
||||||
|
|
|
||||||
28
deploy.sh
28
deploy.sh
|
|
@ -1,6 +1,30 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
cd /opt/supersam
|
cd /opt/supersam
|
||||||
git pull origin main
|
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Deploy starting..."
|
||||||
|
|
||||||
|
# Pull latest from main
|
||||||
|
git fetch origin main
|
||||||
|
BEFORE=$(git rev-parse HEAD)
|
||||||
|
git reset --hard origin/main
|
||||||
|
AFTER=$(git rev-parse HEAD)
|
||||||
|
|
||||||
|
if [ "$BEFORE" = "$AFTER" ]; then
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] No changes, skipping rebuild."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Updated: ${BEFORE:0:7} -> ${AFTER:0:7}"
|
||||||
|
|
||||||
|
# Rebuild and restart
|
||||||
docker compose -f docker-compose.app.yml up -d --build
|
docker compose -f docker-compose.app.yml up -d --build
|
||||||
echo 'Deploy completed at' $(date)
|
|
||||||
|
# Wait for container to be healthy
|
||||||
|
sleep 3
|
||||||
|
if docker ps --format '{{.Names}}' | grep -q 'supersam-app'; then
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Deploy complete. Container running."
|
||||||
|
else
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: Container not running after deploy!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
|
||||||
|
|
@ -455,7 +455,6 @@ services:
|
||||||
SUPABASE_PUBLISHABLE_KEYS: "{\"default\":\"${SUPABASE_PUBLISHABLE_KEY:-}\"}"
|
SUPABASE_PUBLISHABLE_KEYS: "{\"default\":\"${SUPABASE_PUBLISHABLE_KEY:-}\"}"
|
||||||
SUPABASE_SECRET_KEYS: "{\"default\":\"${SUPABASE_SECRET_KEY:-}\"}"
|
SUPABASE_SECRET_KEYS: "{\"default\":\"${SUPABASE_SECRET_KEY:-}\"}"
|
||||||
SUPABASE_DB_URL: postgresql://postgres:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
|
SUPABASE_DB_URL: postgresql://postgres:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
|
||||||
APP_ALLOWED_ORIGINS: https://dost.supersamsev.ru,https://supa.supersamsev.ru,https://supasevdev.mkn8n.ru
|
|
||||||
# TODO: Allow configuring VERIFY_JWT per function.
|
# TODO: Allow configuring VERIFY_JWT per function.
|
||||||
VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}"
|
VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}"
|
||||||
command:
|
command:
|
||||||
|
|
|
||||||
|
|
@ -6,25 +6,17 @@
|
||||||
- `src/context/ThemeContext.jsx` — управление светлой и тёмной темой через `data-theme`.
|
- `src/context/ThemeContext.jsx` — управление светлой и тёмной темой через `data-theme`.
|
||||||
- `src/hooks/usePwaStatus.js` — клиентское состояние PWA: online/offline, install prompt, standalone и offline readiness.
|
- `src/hooks/usePwaStatus.js` — клиентское состояние PWA: online/offline, install prompt, standalone и offline readiness.
|
||||||
- `src/hooks/useOrders.js` — локальный state заказов, истории, чатов, фильтров, действий и **сгруппированных наборов доставки** (deliverySetBuckets).
|
- `src/hooks/useOrders.js` — локальный state заказов, истории, чатов, фильтров, действий и **сгруппированных наборов доставки** (deliverySetBuckets).
|
||||||
- `src/hooks/useOrderGroups.js` — работа с группами заказов: загрузка, обновление статусов, ручное согласование доставки и самовывоза. Метод `saveManualDeliveryChoice` принимает `deliveryType`, `pickupDate`, `pickupTimeSlot`.
|
|
||||||
- `src/services/deliverySetViews.js` — чистые функции группировки импортированных заказов в наборы доставки с buckets: «На подходе», «Готово к запуску», «Ожидает клиента», «Нужна ручная работа», «Согласовано», «Завершено».
|
- `src/services/deliverySetViews.js` — чистые функции группировки импортированных заказов в наборы доставки с buckets: «На подходе», «Готово к запуску», «Ожидает клиента», «Нужна ручная работа», «Согласовано», «Завершено».
|
||||||
- `src/services/orderService.js` — чистые функции бизнес-логики заказов, покрытые тестами.
|
- `src/services/orderService.js` — чистые функции бизнес-логики заказов, покрытые тестами.
|
||||||
- `src/services/supabase/orderRepository.js` — адаптер реальных чтений/записей заказов и чатов в Supabase, включая source-поля 1С и delivery-set поля.
|
- `src/services/supabase/orderRepository.js` — адаптер реальных чтений/записей заказов и чатов в Supabase, включая source-поля 1С и delivery-set поля.
|
||||||
- `src/services/supabase/orderGroupRepository.js` — репозиторий групп заказов. Маппинг полей включает `deliveryType`, `pickupDate`, `pickupTimeSlot`. Метод `updateOrderGroupDeliveryChoice` поддерживает как доставку, так и самовывоз.
|
|
||||||
- `src/services/deliveryWorkflow.js` — карта статусов доставки и переходов. Включает статус `pickup` (Самовывоз) с переходами в/из других статусов.
|
|
||||||
- `src/services/orderGroupViews.js` — метки и цвета для статусов, включая `pickup: "Самовывоз"`.
|
|
||||||
- `src/services/deliveryInvitationApi.js` — API для приглашений доставки. `confirmDeliveryChoice` принимает `deliveryType`, `pickupDate`, `pickupTimeSlot`.
|
|
||||||
- `src/services/driverDeliveries.js` — фильтрация и группировка доставок для рабочей области водителя.
|
- `src/services/driverDeliveries.js` — фильтрация и группировка доставок для рабочей области водителя.
|
||||||
- `src/layouts/AppShell.jsx` — общий shell с боковой навигацией, уведомлениями и переключением темы.
|
- `src/layouts/AppShell.jsx` — общий shell с боковой навигацией, уведомлениями и переключением темы.
|
||||||
- `src/components/logistics/LogisticsReadinessBoard.jsx` — интерактивная доска с bucket-ами наборов доставки.
|
- `src/components/logistics/LogisticsReadinessBoard.jsx` — интерактивная доска с bucket-ами наборов доставки.
|
||||||
- `src/components/logistics/DeliverySetDetailPanel.jsx` — детальная карточка набора доставки: source-поля 1С, production-шаги, слоты, действия.
|
- `src/components/logistics/DeliverySetDetailPanel.jsx` — детальная карточка набора доставки: source-поля 1С, production-шаги, слоты, действия.
|
||||||
- `src/components/client/DeliverySlotsPicker.jsx` — публичный виджет выбора даты и половины дня доставки.
|
- `src/components/client/DeliverySlotsPicker.jsx` — публичный виджет выбора даты и половины дня доставки.
|
||||||
- `src/components/client/PickupSlotsPicker.jsx` — публичный виджет выбора даты и половины дня самовывоза. Отображает доступные даты (сегодня до 12:00, завтра, послезавтра, без выходных), слоты «До обеда»/«После обеда», и информационный блок о стоимости хранения: «Бесплатное хранение — 2 рабочих дня. С 3-го рабочего дня — 300₽/день.».
|
- `src/components/client/DeliveryChoiceFlow.jsx` — публичный поток согласования доставки по приглашению.
|
||||||
- `src/components/client/DeliveryChoiceFlow.jsx` — публичный поток согласования доставки или самовывоза по приглашению. Принимает `deliveryType` и показывает динамические лейблы.
|
|
||||||
- `src/components/client/DeliveryStateNotice.jsx` — информационный экран для clients со статусом ссылки.
|
- `src/components/client/DeliveryStateNotice.jsx` — информационный экран для clients со статусом ссылки.
|
||||||
- `src/pages/ClientDeliveryPage.jsx` — публичная страница согласования доставки с вкладками 🚚 Доставка / 🏪 Самовывоз. Переключение типа получения, отображение соответствующего пикера слотов.
|
|
||||||
- `src/components/orders/*` — фильтры, список заказов, карточка заказа, история статусов и поиск по чату.
|
- `src/components/orders/*` — фильтры, список заказов, карточка заказа, история статусов и поиск по чату.
|
||||||
- `src/components/orders/OrderDetailPanel.jsx` — карточка заказа с управлением доставкой и самовывозом. Вкладки Доставка/Самовывоз, поля даты самовывоза и половины дня, кнопка статуса «Самовывоз».
|
|
||||||
- `src/components/orders/OrderEditorPanel.jsx` — создание и редактирование заказа менеджером или администратором.
|
- `src/components/orders/OrderEditorPanel.jsx` — создание и редактирование заказа менеджером или администратором.
|
||||||
- `src/components/dashboard/ProductionQueuePanel.jsx` — отдельный блок производственной очереди.
|
- `src/components/dashboard/ProductionQueuePanel.jsx` — отдельный блок производственной очереди.
|
||||||
- `src/components/dashboard/RoleWorkspacePanel.jsx` — рабочая панель с delivery-set bucket-ами для логиста.
|
- `src/components/dashboard/RoleWorkspacePanel.jsx` — рабочая панель с delivery-set bucket-ами для логиста.
|
||||||
|
|
@ -39,42 +31,15 @@
|
||||||
- **Логист** видит наборы доставки, слоты, сообщения чатбота и ручную обработку исключений.
|
- **Логист** видит наборы доставки, слоты, сообщения чатбота и ручную обработку исключений.
|
||||||
- **Водитель** видит только назначенные доставки и может переводить их через статусы `Загружен`, `В пути`, `Доставлен`, `Проблема доставки`.
|
- **Водитель** видит только назначенные доставки и может переводить их через статусы `Загружен`, `В пути`, `Доставлен`, `Проблема доставки`.
|
||||||
- **Администратор** видит весь массив заказов, доставок и системные логи.
|
- **Администратор** видит весь массив заказов, доставок и системные логи.
|
||||||
- **Менеджер** может назначить тип доставки (доставка/самовывоз), дату и половину дня.
|
|
||||||
- Клиент не является авторизованным пользователем приложения. Клиент использует публичную ссылку приглашения.
|
- Клиент не является авторизованным пользователем приложения. Клиент использует публичную ссылку приглашения.
|
||||||
|
|
||||||
## Ключевые экраны
|
## Ключевые экраны
|
||||||
|
|
||||||
- `/login` — email + OTP flow. При отсутствии `VITE_SUPABASE_*` включается demo-режим. Подсказка проверять входящие и спам. Неизвестный email: «Email не найден в системе. Обратитесь к администратору.»
|
- `/login` — email + OTP flow. При отсутствии `VITE_SUPABASE_*` включается demo-режим. Подсказка проверять входящие и спам. Неизвестный email: «Email не найден в системе. Обратитесь к администратору.»
|
||||||
- `/dashboard` — role-based control center: для логиста — LogisticsReadinessBoard с наборами доставки, для водителя — план маршрута и быстрые действия.
|
- `/dashboard` — role-based control center: для логиста — LogisticsReadinessBoard с наборами доставки, для водителя — план маршрута и быстрые действия.
|
||||||
- `/delivery/:token` — публичная страница согласования доставки для клиента с вкладками Доставка/Самовывоз.
|
- `/delivery/:token` — публичная страница согласования доставки для клиента.
|
||||||
- `public/manifest.webmanifest` + `public/service-worker.js` — installable PWA-оболочка и базовое кеширование shell для demo offline.
|
- `public/manifest.webmanifest` + `public/service-worker.js` — installable PWA-оболочка и базовое кеширование shell для demo offline.
|
||||||
|
|
||||||
## Тип получения: Доставка и Самовывоз
|
|
||||||
|
|
||||||
### Переключатель типа
|
|
||||||
|
|
||||||
На клиентской странице (`ClientDeliveryPage`) и в карточке заказа (`OrderDetailPanel`) реализованы вкладки:
|
|
||||||
- **🚚 Доставка** — стандартный флоу с выбором даты и половины дня.
|
|
||||||
- **🏪 Самовывоз** — выбор даты самовывоза (сегодня/завтра/послезавтра, без выходных) и половины дня.
|
|
||||||
|
|
||||||
### Статус «Самовывоз»
|
|
||||||
|
|
||||||
В `deliveryWorkflow.js` добавлен статус `pickup` с переходами:
|
|
||||||
- `pending_confirmation` → `pickup`
|
|
||||||
- `manual_confirmation_required` → `pickup`
|
|
||||||
- `pickup` → `assigned_to_driver`, `delivered`, `cancelled`
|
|
||||||
|
|
||||||
### База данных
|
|
||||||
|
|
||||||
Колонки в `order_groups`:
|
|
||||||
- `delivery_type text DEFAULT 'delivery'` — тип получения
|
|
||||||
- `pickup_date date` — дата самовывоза
|
|
||||||
- `pickup_time_slot text` — «До обеда» / «После обеда»
|
|
||||||
|
|
||||||
RPC `confirm_delivery_choice_by_token` обновлён: при `delivery_type = 'pickup'` устанавливает `delivery_status = 'pickup'`, `pickup_date` и `pickup_time_slot`.
|
|
||||||
|
|
||||||
Edge function `confirm-delivery-choice` передаёт `p_delivery_type`, `p_pickup_date`, `p_pickup_time_slot` в RPC.
|
|
||||||
|
|
||||||
## Источник заказов: 1С → Supabase
|
## Источник заказов: 1С → Supabase
|
||||||
|
|
||||||
- Заказы импортируются из 1С через XML и сохраняются в `public.orders` с source-полями: `source_order_number`, `source_customer_name`, `source_accept_at`, `source_ship_at` и т.д.
|
- Заказы импортируются из 1С через XML и сохраняются в `public.orders` с source-полями: `source_order_number`, `source_customer_name`, `source_accept_at`, `source_ship_at` и т.д.
|
||||||
|
|
@ -94,10 +59,4 @@ Edge function `confirm-delivery-choice` передаёт `p_delivery_type`, `p_p
|
||||||
- `src/supabaseClient.js` создаёт клиент Supabase через env-переменные.
|
- `src/supabaseClient.js` создаёт клиент Supabase через env-переменные.
|
||||||
- `src/services/safeSupabaseCall.js` стандартизирует обработку ошибок.
|
- `src/services/safeSupabaseCall.js` стандартизирует обработку ошибок.
|
||||||
- Данные UI разложены по сущностям, совпадающим с таблицами Supabase: `orders`, `order_history`, `chat_messages`, `delivery_slots`, `delivery_invitations`.
|
- Данные UI разложены по сущностям, совпадающим с таблицами Supabase: `orders`, `order_history`, `chat_messages`, `delivery_slots`, `delivery_invitations`.
|
||||||
- В `orders` синхронизированы поля `status`, `delivery_agreement_status`, `assigned_driver_id`, а также source-поля 1С и delivery-set данные.
|
- В `orders` синхронизированы поля `status`, `delivery_agreement_status`, `assigned_driver_id`, а также source-поля 1С и delivery-set данные.
|
||||||
|
|
||||||
## Деплой
|
|
||||||
|
|
||||||
- Docker-сборка через `docker-compose.app.yml` (multi-stage: Node.js build → Caddy serve).
|
|
||||||
- Автодеплой: Gitea webhook → systemd `supersam-webhook` → `deploy.sh` (git pull + docker build).
|
|
||||||
- Домен: `https://dost.supersamsev.ru/`
|
|
||||||
|
|
@ -26,9 +26,7 @@
|
||||||
- `second_sms_sent_at` - время второй SMS
|
- `second_sms_sent_at` - время второй SMS
|
||||||
- `last_sms_error` - текст последней ошибки провайдера
|
- `last_sms_error` - текст последней ошибки провайдера
|
||||||
- `next_notification_check_at` - когда `n8n` должен вернуться к записи
|
- `next_notification_check_at` - когда `n8n` должен вернуться к записи
|
||||||
- `delivery_date` и `delivery_time` - выбранный слот доставки после подтверждения клиентом
|
- `delivery_date` и `delivery_time` - выбранный слот после подтверждения клиентом
|
||||||
- `delivery_type` - тип получения: `delivery` (доставка) или `pickup` (самовывоз)
|
|
||||||
- `pickup_date` и `pickup_time_slot` - выбранный слот самовывоза после подтверждения клиентом
|
|
||||||
|
|
||||||
## Окно отправки SMS
|
## Окно отправки SMS
|
||||||
|
|
||||||
|
|
@ -68,7 +66,6 @@ public.next_order_group_sms_check_at(start_from timestamptz, delay interval)
|
||||||
- `out_for_delivery` - водитель уже в работе
|
- `out_for_delivery` - водитель уже в работе
|
||||||
- `delivered` - доставка завершена
|
- `delivered` - доставка завершена
|
||||||
- `cancelled` - группу больше не нужно обрабатывать
|
- `cancelled` - группу больше не нужно обрабатывать
|
||||||
- `pickup` - клиент выбрал самовывоз
|
|
||||||
|
|
||||||
### `notification_status`
|
### `notification_status`
|
||||||
|
|
||||||
|
|
@ -278,14 +275,6 @@ docs/sql/order-groups-auto-delivery-link.sql
|
||||||
Никакой логики SMS на фронтенде быть не должно.
|
Никакой логики SMS на фронтенде быть не должно.
|
||||||
Никакой генерации ссылок на фронтенде быть не должно.
|
Никакой генерации ссылок на фронтенде быть не должно.
|
||||||
|
|
||||||
## Самовывоз
|
|
||||||
|
|
||||||
Когда клиент выбирает вкладку «Самовывоз» на публичной странице:
|
|
||||||
|
|
||||||
1. `confirm-delivery-choice` получает `delivery_type = 'pickup'` вместе с `pickup_date` и `pickup_time_slot`.
|
|
||||||
2. RPC `confirm_delivery_choice_by_token` устанавливает `delivery_status = 'pickup'`, `delivery_type = 'pickup'`, `pickup_date`, `pickup_time_slot`.
|
|
||||||
3. SMS-потоки n8n должны игнорировать строки со `delivery_status = 'pickup'` — клиент сам забирает заказ, напоминания о доставке не нужны.
|
|
||||||
|
|
||||||
## Минимальный порядок внедрения
|
## Минимальный порядок внедрения
|
||||||
|
|
||||||
1. Развернуть обновленную схему `Supabase` и `docs/sql/order-groups-auto-delivery-link.sql`.
|
1. Развернуть обновленную схему `Supabase` и `docs/sql/order-groups-auto-delivery-link.sql`.
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
- показать менеджеру единый реестр доставочных заказов с поиском и карточкой заказа;
|
- показать менеджеру единый реестр доставочных заказов с поиском и карточкой заказа;
|
||||||
- показать логисту список доставок на сегодня и ближайшие дни с половинами дня;
|
- показать логисту список доставок на сегодня и ближайшие дни с половинами дня;
|
||||||
- показать водителю свои доставки, адрес, состав заказа и базовые статусы;
|
- показать водителю свои доставки, адрес, состав заказа и базовые статусы;
|
||||||
- дать клиенту публичную ссылку, по которой он выбирает дату и половину дня доставки **или самовывоза**;
|
- дать клиенту публичную ссылку, по которой он выбирает дату и половину дня доставки;
|
||||||
- хранить состояние заказов, приглашений и истории изменений в Supabase.
|
- хранить состояние заказов, приглашений и истории изменений в Supabase.
|
||||||
|
|
||||||
## Роли
|
## Роли
|
||||||
|
|
@ -17,7 +17,6 @@
|
||||||
- видит список заказов доставки;
|
- видит список заказов доставки;
|
||||||
- ищет по номеру заказа, клиенту и телефону;
|
- ищет по номеру заказа, клиенту и телефону;
|
||||||
- открывает карточку заказа и смотрит состав, комментарии и историю;
|
- открывает карточку заказа и смотрит состав, комментарии и историю;
|
||||||
- может назначить тип доставки (доставка/самовывоз), дату самовывоза и половину дня;
|
|
||||||
- не работает с созданием заказов и внутренними служебными экранами.
|
- не работает с созданием заказов и внутренними служебными экранами.
|
||||||
|
|
||||||
### Логист
|
### Логист
|
||||||
|
|
@ -25,8 +24,7 @@
|
||||||
- видит заказы, готовые к доставке;
|
- видит заказы, готовые к доставке;
|
||||||
- смотрит ближайшие даты: сегодня, завтра и послезавтра;
|
- смотрит ближайшие даты: сегодня, завтра и послезавтра;
|
||||||
- смотрит половину дня и текущий статус доставки;
|
- смотрит половину дня и текущий статус доставки;
|
||||||
- открывает карточку заказа, чтобы свериться с деталями;
|
- открывает карточку заказа, чтобы свериться с деталями.
|
||||||
- может перевести статус заказа в «Самовывоз» и указать дату.
|
|
||||||
|
|
||||||
### Водитель
|
### Водитель
|
||||||
|
|
||||||
|
|
@ -38,40 +36,9 @@
|
||||||
|
|
||||||
- получает публичную ссылку вида `/delivery/:token`;
|
- получает публичную ссылку вида `/delivery/:token`;
|
||||||
- видит номер заказа и состав заказа;
|
- видит номер заказа и состав заказа;
|
||||||
- **выбирает тип получения: Доставка или Самовывоз**;
|
- выбирает дату и половину дня: `До обеда` или `После обеда`;
|
||||||
- при выборе доставки — выбирает дату и половину дня: `До обеда` или `После обеда`;
|
|
||||||
- при выборе самовывоза — выбирает дату (сегодня/завтра/послезавтра с учётом выходных) и половину дня;
|
|
||||||
- видит информацию о бесплатном хранении (2 рабочих дня) и платном (300₽/день начиная с 3-го рабочего дня);
|
|
||||||
- подтверждает выбор без входа во внутренний кабинет.
|
- подтверждает выбор без входа во внутренний кабинет.
|
||||||
|
|
||||||
## Самовывоз
|
|
||||||
|
|
||||||
### Клиентский флоу
|
|
||||||
|
|
||||||
1. Клиент открывает ссылку `/delivery/:token`.
|
|
||||||
2. Видит две вкладки: **🚚 Доставка** и **🏪 Самовывоз**.
|
|
||||||
3. На вкладке «Самовывоз» видит:
|
|
||||||
- Доступные даты: сегодня (если до 12:00 текущего дня готовности), завтра, послезавтра (выходные пропускаются).
|
|
||||||
- Две половины дня: «До обеда» и «После обеда».
|
|
||||||
- Информационный блок: «Бесплатное хранение — 2 рабочих дня. С 3-го рабочего дня — 300₽/день.»
|
|
||||||
4. Выбирает дату и половину дня, подтверждает.
|
|
||||||
5. Статус заказа переходит в `pickup`, в БД сохраняются `pickup_date` и `pickup_time_slot`.
|
|
||||||
|
|
||||||
### Управление в карточке заказа
|
|
||||||
|
|
||||||
- Менеджер, логист и администратор могут переключить тип доставки (Доставка ↔ Самовывоз).
|
|
||||||
- При самовывозе доступны поля: дата самовывоза и половина дня.
|
|
||||||
- Статус «Самовывоз» доступен в кнопках смены статуса.
|
|
||||||
|
|
||||||
### База данных
|
|
||||||
|
|
||||||
В таблице `order_groups` добавлены колонки:
|
|
||||||
- `delivery_type text DEFAULT 'delivery'` — тип получения: `delivery` или `pickup`
|
|
||||||
- `pickup_date date` — дата самовывоза
|
|
||||||
- `pickup_time_slot text` — половина дня самовывоза (`До обеда` / `После обеда`)
|
|
||||||
|
|
||||||
Статус `delivery_status` пополнился значением `pickup` — «Самовывоз».
|
|
||||||
|
|
||||||
## Основные сценарии
|
## Основные сценарии
|
||||||
|
|
||||||
### Внутренний сценарий
|
### Внутренний сценарий
|
||||||
|
|
@ -81,7 +48,7 @@
|
||||||
3. Логист отслеживает готовность и ближайшее окно доставки.
|
3. Логист отслеживает готовность и ближайшее окно доставки.
|
||||||
4. Водитель получает свою доставку и доводит её до результата.
|
4. Водитель получает свою доставку и доводит её до результата.
|
||||||
|
|
||||||
### Сценарий клиента (доставка)
|
### Сценарий клиента
|
||||||
|
|
||||||
Клиентская страница работает по token из таблицы `public.delivery_invitations`.
|
Клиентская страница работает по token из таблицы `public.delivery_invitations`.
|
||||||
|
|
||||||
|
|
@ -92,27 +59,21 @@
|
||||||
Эта ссылка показывает:
|
Эта ссылка показывает:
|
||||||
- заказ `CD-240031`;
|
- заказ `CD-240031`;
|
||||||
- состав заказа;
|
- состав заказа;
|
||||||
- вкладки «Доставка» и «Самовывоз»;
|
- четыре варианта слота;
|
||||||
- на вкладке «Доставка» — четыре варианта слота, две даты, две половины дня.
|
- две даты;
|
||||||
|
- две половины дня: `До обеда` и `После обеда`.
|
||||||
|
|
||||||
### Сценарий клиента (самовывоз)
|
После подтверждения выбора:
|
||||||
|
- invitation переводится в состояние `agreed`;
|
||||||
При выборе вкладки «Самовывоз»:
|
- заказ переводится в `Доставка согласована`;
|
||||||
- доступны даты начиная с дня готовности (если до 12:00) или завтра;
|
- в `order_history` появляется запись о подтверждении;
|
||||||
- две половины дня: «До обеда» и «После обеда»;
|
- в `delivery_slots` фиксируется подтверждённый слот.
|
||||||
- информационный блок о стоимости хранения;
|
|
||||||
- подтверждение устанавливает `delivery_type = 'pickup'`, `delivery_status = 'pickup'`.
|
|
||||||
|
|
||||||
## Что хранится в Supabase
|
## Что хранится в Supabase
|
||||||
|
|
||||||
- `public.users` — пользователи и роли;
|
- `public.users` — пользователи и роли;
|
||||||
- `public.orders` — заказы и текущие статусы;
|
- `public.orders` — заказы и текущие статусы;
|
||||||
- `public.order_history` — история изменений;
|
- `public.order_history` — история изменений;
|
||||||
- `public.order_groups` — группы заказов с полями доставки и самовывоза:
|
|
||||||
- `delivery_type` — `delivery` или `pickup`;
|
|
||||||
- `delivery_date`, `delivery_time` — слот доставки;
|
|
||||||
- `pickup_date`, `pickup_time_slot` — слот самовывоза;
|
|
||||||
- `delivery_status` — статус согласования (включая `pickup`);
|
|
||||||
- `public.delivery_slots` — возможные и подтверждённые слоты доставки;
|
- `public.delivery_slots` — возможные и подтверждённые слоты доставки;
|
||||||
- `public.delivery_invitations` — публичные invitation token и состояние клиентского flow;
|
- `public.delivery_invitations` — публичные invitation token и состояние клиентского flow;
|
||||||
- `public.integration_events` — технические и интеграционные события.
|
- `public.integration_events` — технические и интеграционные события.
|
||||||
|
|
@ -134,13 +95,11 @@
|
||||||
- реестр заказов и карточку заказа;
|
- реестр заказов и карточку заказа;
|
||||||
- список доставок по датам для логиста;
|
- список доставок по датам для логиста;
|
||||||
- карточку доставки водителя;
|
- карточку доставки водителя;
|
||||||
- клиентскую ссылку с выбором типа получения (доставка/самовывоз) и датой;
|
- клиентскую ссылку с выбором даты и половины дня.
|
||||||
- информационный блок о стоимости хранения при самовывозе.
|
|
||||||
|
|
||||||
## Полезные документы
|
## Полезные документы
|
||||||
|
|
||||||
- [README](../README.md)
|
- [README](/Users/mihailkucer/Documents/super-sam/README.md)
|
||||||
- [Архитектура](architecture.md)
|
- [Архитектура](/Users/mihailkucer/Documents/super-sam/docs/architecture.md)
|
||||||
- [Сценарии](scenarios.md)
|
- [Сценарии](/Users/mihailkucer/Documents/super-sam/docs/scenarios.md)
|
||||||
- [Поток n8n](n8n-order-group-delivery-flow.md)
|
- [Edge Functions](/Users/mihailkucer/Documents/super-sam/supabase/functions/README.md)
|
||||||
- [Edge Functions](../supabase/functions/README.md)
|
|
||||||
|
|
|
||||||
|
|
@ -20,16 +20,15 @@
|
||||||
- Перечнем заказов набора, их 1С-номерами и шагами производства (раскрой, склейка, криволинейные, контроль качества, отгрузка).
|
- Перечнем заказов набора, их 1С-номерами и шагами производства (раскрой, склейка, криволинейные, контроль качества, отгрузка).
|
||||||
- Телефоном и email клиента, городом, связанными счетами.
|
- Телефоном и email клиента, городом, связанными счетами.
|
||||||
- Текущим статусом слота.
|
- Текущим статусом слота.
|
||||||
|
3. Логист может запустить приглашение, назначить водителя или перейти к ручной обработке.
|
||||||
|
|
||||||
## 3. Согласование доставки с клиентом
|
## 3. Согласование доставки с клиентом
|
||||||
|
|
||||||
1. Когда набор доставки готов, логист запускает отправку приглашения клиенту.
|
1. Когда набор доставки готов, логист запускает отправку приглашения клиенту.
|
||||||
2. Клиент получает ссылку на `/delivery/:token`.
|
2. Клиент получает ссылку на `/delivery/:token`.
|
||||||
3. На странице клиент видит вкладки **🚚 Доставка** и **🏪 Самовывоз**.
|
3. На странице клиент видит **DeliverySlotsPicker** с доступными датами и половинами дня.
|
||||||
4. На вкладке «Доставка» — **DeliverySlotsPicker** с доступными датами и половинами дня.
|
4. Клиент выбирает слот и подтверждает. Статус набора переходит в «Ожидает клиента» → «Согласовано».
|
||||||
5. На вкладке «Самовывоз» — **PickupSlotsPicker** с датами (сегодня до 12:00, завтра, послезавтра, без выходных) и половинами дня, а также информационный блок: «Бесплатное хранение — 2 рабочих дня. С 3-го рабочего дня — 300₽/день.»
|
5. Если клиент не отвечает, система или логист переводит набор в «Нужна ручная работа».
|
||||||
6. Клиент выбирает тип получения, слот и подтверждает. Статус набора переходит в «Ожидает клиента» → «Согласовано» или «Самовывоз».
|
|
||||||
7. Если клиент не отвечает, система или логист переводит набор в «Нужна ручная работа».
|
|
||||||
|
|
||||||
## 4. Перенос доставки
|
## 4. Перенос доставки
|
||||||
|
|
||||||
|
|
@ -56,30 +55,6 @@
|
||||||
2. После закрытия всех заказов набора он переходит в «Завершено».
|
2. После закрытия всех заказов набора он переходит в «Завершено».
|
||||||
3. В истории появляется финальная запись, а чат закрывается для активных действий.
|
3. В истории появляется финальная запись, а чат закрывается для активных действий.
|
||||||
|
|
||||||
## 8. Самовывоз
|
|
||||||
|
|
||||||
### Клиентский сценарий
|
|
||||||
|
|
||||||
1. Клиент открывает ссылку `/delivery/:token`.
|
|
||||||
2. Выбирает вкладку **🏪 Самовывоз**.
|
|
||||||
3. Видит доступные даты: сегодня (если до 12:00 текущего дня готовности), завтра, послезавтра (выходные пропускаются).
|
|
||||||
4. Выбирает дату и половину дня: «До обеда» или «После обеда».
|
|
||||||
5. Видит информационный блок: «Бесплатное хранение — 2 рабочих дня. С 3-го рабочего дня — 300₽/день.»
|
|
||||||
6. Подтверждает выбор.
|
|
||||||
7. В БД устанавливаются: `delivery_type = 'pickup'`, `delivery_status = 'pickup'`, `pickup_date`, `pickup_time_slot`.
|
|
||||||
|
|
||||||
### Сценарий менеджера/логиста
|
|
||||||
|
|
||||||
1. В карточке заказа (**OrderDetailPanel**) менеджер видит вкладки Доставка/Самовывоз.
|
|
||||||
2. При выборе «Самовывоз» — поля даты и половины дня для самовывоза.
|
|
||||||
3. Кнопка статуса «Самовывоз» доступна для менеджера, логиста и администратора.
|
|
||||||
4. Менеджер может переключить тип доставки в любой момент до передачи водителю.
|
|
||||||
|
|
||||||
### Статусы самовывоза
|
|
||||||
|
|
||||||
- `pickup` — заказ ожидает самовывоза клиентом.
|
|
||||||
- Переходы: `pending_confirmation` → `pickup`, `manual_confirmation_required` → `pickup`, `pickup` → `assigned_to_driver`, `pickup` → `delivered`, `pickup` → `cancelled`.
|
|
||||||
|
|
||||||
## Сценарий показа заказчику
|
## Сценарий показа заказчику
|
||||||
|
|
||||||
1. Зайти под логистом.
|
1. Зайти под логистом.
|
||||||
|
|
@ -90,6 +65,6 @@
|
||||||
- Фролова И.Д. — «Нужна ручная работа» (платное хранение).
|
- Фролова И.Д. — «Нужна ручная работа» (платное хранение).
|
||||||
- Орлова Н.С. — «Завершено».
|
- Орлова Н.С. — «Завершено».
|
||||||
3. Кликнуть по набору Савина — увидеть source-поля, production-шаги, готовность к запуску.
|
3. Кликнуть по набору Савина — увидеть source-поля, production-шаги, готовность к запуску.
|
||||||
4. Перейти на публичную страницу приглашения — увидеть вкладки «Доставка» и «Самовывоз» с выбором даты и половины дня.
|
4. Перейти на публичную страницу приглашения — увидеть `DeliverySlotsPicker` с выбором даты и половины дня.
|
||||||
5. Зайти под водителем — увидеть назначенные доставки с адресами и быстрыми действиями.
|
5. Зайти под водителем — увидеть назначенные доставки с адресами и быстрыми действиями.
|
||||||
6. Зайти под несуществующим email — увидеть «Email не найден в системе. Обратитесь к администратору.»
|
6. Зайти под несуществующим email — увидеть «Email не найден в системе. Обратитесь к администратору.»
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,14 @@
|
||||||
<meta name="theme-color" content="#12805c" />
|
<meta name="theme-color" content="#12805c" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
<meta name="apple-mobile-web-app-title" content="SuperSam" />
|
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="Панель управления доставкой и заказами с офлайн-доступом после первого запуска."
|
content="Демо-панель управления доставкой и заказами с офлайн-доступом после первого запуска."
|
||||||
/>
|
/>
|
||||||
<link rel="icon" type="image/png" href="/icons/icon-192.png" />
|
<link rel="icon" type="image/svg+xml" href="/icons/icon-192.svg" />
|
||||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<title>SuperSam Доставка</title>
|
<title>Construction Delivery Control</title>
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
|
|
@ -10,28 +10,16 @@
|
||||||
"lang": "ru",
|
"lang": "ru",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/icons/icon-192.png",
|
"src": "/icons/icon-192.svg",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png",
|
"type": "image/svg+xml",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icons/icon-512.png",
|
"src": "/icons/icon-512.svg",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/svg+xml",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/icons/icon-192.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "maskable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/icons/icon-512.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "maskable"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
|
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
|
||||||
|
|
||||||
if (!isLocalhost) {
|
if (!isLocalhost) {
|
||||||
const STATIC_CACHE = "construction-delivery-static-v5";
|
const STATIC_CACHE = "construction-delivery-static-v4";
|
||||||
const RUNTIME_CACHE = "construction-delivery-runtime-v5";
|
const RUNTIME_CACHE = "construction-delivery-runtime-v4";
|
||||||
const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
|
const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.svg", "/icons/icon-512.svg"];
|
||||||
|
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
|
|
@ -93,8 +93,8 @@ self.addEventListener("push", (event) => {
|
||||||
const title = data.title || "Уведомление";
|
const title = data.title || "Уведомление";
|
||||||
const options = {
|
const options = {
|
||||||
body: data.body || "",
|
body: data.body || "",
|
||||||
icon: data.icon || "/icons/icon-192.png",
|
icon: data.icon || "/icons/icon-192.svg",
|
||||||
badge: data.badge || "/icons/icon-192.png",
|
badge: data.badge || "/icons/icon-192.svg",
|
||||||
data: data.data || {},
|
data: data.data || {},
|
||||||
tag: data.tag || "default",
|
tag: data.tag || "default",
|
||||||
vibrate: [100, 50, 100],
|
vibrate: [100, 50, 100],
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ class ErrorBoundary extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidCatch(error, errorInfo) {
|
componentDidCatch(error, errorInfo) {
|
||||||
|
// Extract component stack for richer context
|
||||||
const componentInfo = {
|
const componentInfo = {
|
||||||
component: errorInfo?.componentStack || null,
|
component: errorInfo?.componentStack || null,
|
||||||
props: this.props,
|
props: this.props,
|
||||||
|
|
@ -24,61 +25,47 @@ class ErrorBoundary extends React.Component {
|
||||||
this.setState({ hasError: false, error: null });
|
this.setState({ hasError: false, error: null });
|
||||||
};
|
};
|
||||||
|
|
||||||
handleReload = () => {
|
|
||||||
window.location.reload();
|
|
||||||
};
|
|
||||||
|
|
||||||
renderDefaultFallback() {
|
renderDefaultFallback() {
|
||||||
const { compact } = this.props;
|
|
||||||
|
|
||||||
if (compact) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-[24px] border border-[var(--color-error)] bg-[var(--color-error-soft)] p-4 text-center">
|
|
||||||
<p className="text-sm text-[var(--color-text)]">Что-то пошло не так</p>
|
|
||||||
<button
|
|
||||||
onClick={this.handleRetry}
|
|
||||||
className="mt-2 rounded-xl bg-[var(--color-primary)] px-4 py-2 text-sm font-semibold text-white"
|
|
||||||
>
|
|
||||||
Попробовать снова
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[460px] flex-col items-center justify-center p-8 text-center">
|
<div
|
||||||
<div className="mb-4 text-5xl">⚠️</div>
|
style={{
|
||||||
<h2 className="mb-2 text-xl font-semibold text-[var(--color-text)]">
|
display: 'flex',
|
||||||
Что-то пошло не так
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '2rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
minHeight: '200px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 style={{ marginBottom: '0.5rem', color: '#e53e3e' }}>
|
||||||
|
Something went wrong
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mb-6 max-w-md text-sm text-[var(--color-text-muted)]">
|
<p style={{ marginBottom: '1rem', color: '#718096', fontSize: '0.9rem' }}>
|
||||||
Произошла непредвиденная ошибка. Попробуйте обновить страницу или вернуться позже.
|
An unexpected error occurred. You can try again.
|
||||||
</p>
|
</p>
|
||||||
{this.state.error && process.env.NODE_ENV === 'development' && (
|
<button
|
||||||
<pre className="mb-4 max-h-32 overflow-auto rounded-xl bg-[var(--color-surface-strong)] p-3 text-left text-xs text-[var(--color-error)]">
|
onClick={this.handleRetry}
|
||||||
{this.state.error.message}
|
style={{
|
||||||
</pre>
|
padding: '0.5rem 1.25rem',
|
||||||
)}
|
fontSize: '0.9rem',
|
||||||
<div className="flex gap-3">
|
fontWeight: 600,
|
||||||
<button
|
color: '#fff',
|
||||||
onClick={this.handleRetry}
|
backgroundColor: '#3182ce',
|
||||||
className="rounded-xl bg-[var(--color-primary)] px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:opacity-90"
|
border: 'none',
|
||||||
>
|
borderRadius: '6px',
|
||||||
Попробовать снова
|
cursor: 'pointer',
|
||||||
</button>
|
}}
|
||||||
<button
|
>
|
||||||
onClick={this.handleReload}
|
Try Again
|
||||||
className="rounded-xl border border-[var(--color-border)] px-5 py-2.5 text-sm font-semibold text-[var(--color-text)] transition-colors hover:bg-[var(--color-surface)]"
|
</button>
|
||||||
>
|
|
||||||
Обновить страницу
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
|
// Allow custom fallback render function
|
||||||
if (typeof this.props.fallback === 'function') {
|
if (typeof this.props.fallback === 'function') {
|
||||||
return this.props.fallback(this.state.error, this.handleRetry);
|
return this.props.fallback(this.state.error, this.handleRetry);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ export const PwaInstallButton = ({ onInstall, isInstalled, isInstallAvailable })
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showTip && (
|
{showTip && (
|
||||||
<div className="absolute left-1/2 -translate-x-1/2 top-full z-50 mt-2 w-60 sm:left-auto sm:translate-x-0 sm:right-0 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3 text-sm shadow-lg">
|
<div className="absolute right-0 top-full z-50 mt-2 w-60 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3 text-sm shadow-lg">
|
||||||
{isIOS ? (
|
{isIOS ? (
|
||||||
<>
|
<>
|
||||||
<p className="font-medium">Установка на iOS</p>
|
<p className="font-medium">Установка на iOS</p>
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,13 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { Button } from "./Button";
|
||||||
import { useTheme } from "../../context/ThemeContext";
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
|
|
||||||
export const ThemeToggle = () => {
|
export const ThemeToggle = () => {
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<Button variant="secondary" size="sm" onClick={toggleTheme}>
|
||||||
type="button"
|
{theme === "light" ? "Тёмная тема" : "Светлая тема"}
|
||||||
onClick={toggleTheme}
|
</Button>
|
||||||
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-base transition hover:bg-[var(--color-accent-soft)]"
|
|
||||||
aria-label={theme === "light" ? "Тёмная тема" : "Светлая тема"}
|
|
||||||
title={theme === "light" ? "Тёмная тема" : "Светлая тема"}
|
|
||||||
>
|
|
||||||
{theme === "light" ? "🌙" : "☀️"}
|
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@ import { Badge } from '../UI/Badge';
|
||||||
import { SegmentedTabs } from '../UI/SegmentedTabs';
|
import { SegmentedTabs } from '../UI/SegmentedTabs';
|
||||||
import { Skeleton } from '../UI/Loading';
|
import { Skeleton } from '../UI/Loading';
|
||||||
import { useAdminStats } from '../../hooks/useAdminStats';
|
import { useAdminStats } from '../../hooks/useAdminStats';
|
||||||
import { usePickupStats } from '../../hooks/usePickupStats';
|
|
||||||
import { PickupStatsPanel } from './PickupStatsPanel';
|
|
||||||
|
|
||||||
// ── Mobile Detection Hook ───────────────────────────────────────────────────
|
// ── Mobile Detection Hook ───────────────────────────────────────────────────
|
||||||
const useIsMobile = () => {
|
const useIsMobile = () => {
|
||||||
|
|
@ -42,7 +40,6 @@ const STATUS_COLORS = {
|
||||||
paid_storage: '#06b6d4',
|
paid_storage: '#06b6d4',
|
||||||
problem: '#ef4444',
|
problem: '#ef4444',
|
||||||
cancelled: '#64748b',
|
cancelled: '#64748b',
|
||||||
pickup: '#f59e0b',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_LABELS = {
|
const STATUS_LABELS = {
|
||||||
|
|
@ -56,7 +53,6 @@ const STATUS_LABELS = {
|
||||||
paid_storage: 'Оплаченное хранение',
|
paid_storage: 'Оплаченное хранение',
|
||||||
problem: 'Проблема',
|
problem: 'Проблема',
|
||||||
cancelled: 'Отменено',
|
cancelled: 'Отменено',
|
||||||
pickup: 'Самовывоз',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Period Selector Options ────────────────────────────────────────────────
|
// ── Period Selector Options ────────────────────────────────────────────────
|
||||||
|
|
@ -91,15 +87,6 @@ export const AdminDashboard = () => {
|
||||||
const [period, setPeriod] = useState('7d');
|
const [period, setPeriod] = useState('7d');
|
||||||
const mobile = useIsMobile();
|
const mobile = useIsMobile();
|
||||||
const { stats, statusDist, dailyTrend, driverStats, economics, isLoading, error, refetch } = useAdminStats(period);
|
const { stats, statusDist, dailyTrend, driverStats, economics, isLoading, error, refetch } = useAdminStats(period);
|
||||||
const { stats: pickupStats, isLoading: pickupLoading } = usePickupStats(period);
|
|
||||||
|
|
||||||
// ── Responsive Layout Values (must be before early returns) ────────────────
|
|
||||||
const chartHeight = mobile ? 200 : 240;
|
|
||||||
const kpiMin = mobile ? '80px' : '110px';
|
|
||||||
const chartGridCols = mobile ? '1fr' : '1fr 2fr';
|
|
||||||
const driverLabelWidth = mobile ? 80 : 120;
|
|
||||||
const fontSize = mobile ? { xs: '0.6rem', s: '0.7rem', m: '0.78rem', l: '0.85rem', xl: '1rem' }
|
|
||||||
: { xs: '0.65rem', s: '0.68rem', m: '0.78rem', l: '0.85rem', xl: '1.1rem' };
|
|
||||||
|
|
||||||
// ── Loading / Error States ─────────────────────────────────────────────────
|
// ── Loading / Error States ─────────────────────────────────────────────────
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
|
@ -167,6 +154,14 @@ export const AdminDashboard = () => {
|
||||||
{ label: 'Отмена', value: econ.cancelled_count || 0, color: '#ef4444' },
|
{ label: 'Отмена', value: econ.cancelled_count || 0, color: '#ef4444' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ── Responsive Layout Values ──────────────────────────────────────────────
|
||||||
|
const chartHeight = mobile ? 200 : 240;
|
||||||
|
const kpiMin = mobile ? '80px' : '110px';
|
||||||
|
const chartGridCols = mobile ? '1fr' : '1fr 2fr';
|
||||||
|
const driverLabelWidth = mobile ? 80 : 120;
|
||||||
|
const fontSize = mobile ? { xs: '0.6rem', s: '0.7rem', m: '0.78rem', l: '0.85rem', xl: '1rem' }
|
||||||
|
: { xs: '0.65rem', s: '0.68rem', m: '0.78rem', l: '0.85rem', xl: '1.1rem' };
|
||||||
|
|
||||||
// ── Render ─────────────────────────────────────────────────────────────────
|
// ── Render ─────────────────────────────────────────────────────────────────
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: mobile ? '0.75rem' : '1.25rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: mobile ? '0.75rem' : '1.25rem' }}>
|
||||||
|
|
@ -349,9 +344,6 @@ export const AdminDashboard = () => {
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{/* Pickup Stats */}
|
|
||||||
<PickupStatsPanel stats={pickupStats} isLoading={pickupLoading} mobile={mobile} fontSize={fontSize} />
|
|
||||||
|
|
||||||
{/* Drivers */}
|
{/* Drivers */}
|
||||||
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
|
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
|
||||||
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.4rem', color: 'var(--color-text)' }}>По водителям</h3>
|
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.4rem', color: 'var(--color-text)' }}>По водителям</h3>
|
||||||
|
|
|
||||||
|
|
@ -1,167 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts';
|
|
||||||
import { Panel } from '../UI/Panel';
|
|
||||||
|
|
||||||
const PICKUP_COLORS = {
|
|
||||||
today: '#22c55e',
|
|
||||||
tomorrow: '#3b82f6',
|
|
||||||
dayAfter: '#8b5cf6',
|
|
||||||
firstHalf: '#f59e0b',
|
|
||||||
secondHalf: '#ef4444',
|
|
||||||
saturday: '#06b6d4',
|
|
||||||
pickup: '#f59e0b',
|
|
||||||
delivery: '#6366f1',
|
|
||||||
};
|
|
||||||
|
|
||||||
const CustomTooltip = ({ active, payload }) => {
|
|
||||||
if (!active || !payload?.length) return null;
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
background: 'var(--color-surface, #1e293d)',
|
|
||||||
border: '1px solid var(--color-border, #334155)',
|
|
||||||
borderRadius: '12px', padding: '8px 12px', fontSize: '0.8rem',
|
|
||||||
color: 'var(--color-text, #e2e8f0)',
|
|
||||||
}}>
|
|
||||||
{payload.map((p, i) => (
|
|
||||||
<div key={i} style={{ color: p.payload?.fill || p.color }}>
|
|
||||||
{p.name}: <strong>{p.value}</strong>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ProgressBar = ({ label, value, max, color, fontSize, mobile }) => {
|
|
||||||
const pct = max > 0 ? Math.max(0, (value / max) * 100) : 0;
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
|
||||||
<div style={{ flex: '0 0 auto', width: mobile ? '70px' : '100px', fontSize: fontSize.xs, color: 'var(--color-text-muted)', textAlign: 'right' }}>{label}</div>
|
|
||||||
<div style={{ flex: '1 1 auto', height: mobile ? '16px' : '20px', background: 'var(--color-border, rgba(51,65,85,0.4))', borderRadius: '4px', overflow: 'hidden' }}>
|
|
||||||
<div style={{ width: pct + '%', height: '100%', background: color, borderRadius: '4px', transition: 'width 0.4s ease', minWidth: pct > 0 ? '4px' : '0' }} />
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: '0 0 auto', width: mobile ? '36px' : '45px', fontSize: fontSize.s, fontWeight: 600, color: 'var(--color-text)', textAlign: 'left' }}>{value}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PickupStatsPanel = ({ stats, isLoading, mobile, fontSize }) => {
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<Panel>
|
|
||||||
<div style={{ textAlign: 'center', padding: '1.5rem', color: 'var(--color-text-muted)', fontSize: fontSize?.m }}>
|
|
||||||
Загрузка статистики самовывоза...
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!stats) {
|
|
||||||
return (
|
|
||||||
<Panel>
|
|
||||||
<div style={{ textAlign: 'center', padding: '1.5rem', color: 'var(--color-text-muted)', fontSize: fontSize?.m }}>
|
|
||||||
Нет данных по самовывозу
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fs = fontSize || { xs: '0.65rem', s: '0.68rem', m: '0.78rem', l: '0.85rem', xl: '1.1rem' };
|
|
||||||
const totalPickups = Number(stats.total_pickups) || 0;
|
|
||||||
const pickupRate = Number(stats.pickup_rate) || 0;
|
|
||||||
const avgDays = stats.avg_days_until_pickup != null ? Number(stats.avg_days_until_pickup) : null;
|
|
||||||
const dist = stats.delivery_type_dist || {};
|
|
||||||
|
|
||||||
const maxDay = Math.max(
|
|
||||||
Number(stats.pickup_today) || 0,
|
|
||||||
Number(stats.pickup_tomorrow) || 0,
|
|
||||||
Number(stats.pickup_day_after) || 0,
|
|
||||||
1
|
|
||||||
);
|
|
||||||
|
|
||||||
const maxHalf = Math.max(
|
|
||||||
Number(stats.pickup_first_half) || 0,
|
|
||||||
Number(stats.pickup_second_half) || 0,
|
|
||||||
1
|
|
||||||
);
|
|
||||||
|
|
||||||
const pieData = [
|
|
||||||
{ name: 'Самовывоз', value: Number(dist.pickup) || 0, fill: PICKUP_COLORS.pickup },
|
|
||||||
{ name: 'Доставка', value: Number(dist.delivery) || 0, fill: PICKUP_COLORS.delivery },
|
|
||||||
].filter(d => d.value > 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
|
|
||||||
<h3 style={{ fontSize: fs.l, fontWeight: 600, marginBottom: '0.5rem', color: 'var(--color-text)' }}>
|
|
||||||
📦 Самовывоз
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{/* KPI row */}
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr 1fr' : '1fr 1fr 1fr', gap: '0.5rem', marginBottom: '0.75rem' }}>
|
|
||||||
{[
|
|
||||||
{ label: 'Всего самовывоз', val: totalPickups, color: '#f59e0b' },
|
|
||||||
{ label: 'Доля самовывоза', val: pickupRate + '%', color: '#f59e0b' },
|
|
||||||
{ label: 'Ср. дней до выдачи', val: avgDays !== null ? avgDays : '—', color: '#3b82f6' },
|
|
||||||
].map((kpi, i) => (
|
|
||||||
<div key={i} style={{ textAlign: 'center' }}>
|
|
||||||
<div style={{ fontSize: fs.xs, color: 'var(--color-text-muted)', marginBottom: '1px' }}>{kpi.label}</div>
|
|
||||||
<div style={{ fontSize: mobile ? '1rem' : '1.2rem', fontWeight: 700, color: kpi.color }}>{kpi.val}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Distribution by day */}
|
|
||||||
<div style={{ marginBottom: '0.6rem' }}>
|
|
||||||
<div style={{ fontSize: fs.m, fontWeight: 600, color: 'var(--color-text)', marginBottom: '0.3rem' }}>По дням</div>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
|
|
||||||
<ProgressBar label="Сегодня" value={Number(stats.pickup_today) || 0} max={maxDay} color={PICKUP_COLORS.today} fontSize={fs} mobile={mobile} />
|
|
||||||
<ProgressBar label="Завтра" value={Number(stats.pickup_tomorrow) || 0} max={maxDay} color={PICKUP_COLORS.tomorrow} fontSize={fs} mobile={mobile} />
|
|
||||||
<ProgressBar label="Послезавтра" value={Number(stats.pickup_day_after) || 0} max={maxDay} color={PICKUP_COLORS.dayAfter} fontSize={fs} mobile={mobile} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Half-day split */}
|
|
||||||
<div style={{ marginBottom: '0.6rem' }}>
|
|
||||||
<div style={{ fontSize: fs.m, fontWeight: 600, color: 'var(--color-text)', marginBottom: '0.3rem' }}>По времени</div>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
|
|
||||||
<ProgressBar label="До обеда" value={Number(stats.pickup_first_half) || 0} max={maxHalf} color={PICKUP_COLORS.firstHalf} fontSize={fs} mobile={mobile} />
|
|
||||||
<ProgressBar label="После обеда" value={Number(stats.pickup_second_half) || 0} max={maxHalf} color={PICKUP_COLORS.secondHalf} fontSize={fs} mobile={mobile} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Saturday */}
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.3rem 0', borderTop: '1px solid var(--color-border)', marginBottom: '0.6rem' }}>
|
|
||||||
<span style={{ fontSize: fs.s, color: 'var(--color-text-muted)' }}>Самовывоз в субботу</span>
|
|
||||||
<span style={{ fontSize: fs.l, fontWeight: 700, color: PICKUP_COLORS.saturday }}>{Number(stats.pickup_on_saturday) || 0}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Delivery vs Pickup donut */}
|
|
||||||
{pieData.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: fs.m, fontWeight: 600, color: 'var(--color-text)', marginBottom: '0.3rem' }}>Доставка vs Самовывоз</div>
|
|
||||||
<ResponsiveContainer width="100%" height={mobile ? 140 : 170}>
|
|
||||||
<PieChart>
|
|
||||||
<Pie data={pieData} cx="50%" cy="50%"
|
|
||||||
innerRadius={mobile ? 30 : 40}
|
|
||||||
outerRadius={mobile ? 55 : 70}
|
|
||||||
dataKey="value" nameKey="name" paddingAngle={2}
|
|
||||||
>
|
|
||||||
{pieData.map((entry, i) => (
|
|
||||||
<Cell key={i} fill={entry.fill} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip content={<CustomTooltip />} />
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', marginTop: '0.2rem' }}>
|
|
||||||
{pieData.map((d, i) => (
|
|
||||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: fs.xs }}>
|
|
||||||
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: d.fill }} />
|
|
||||||
<span style={{ color: 'var(--color-text-muted)' }}>{d.name}: <strong style={{ color: 'var(--color-text)' }}>{d.value}</strong></span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -6,12 +6,10 @@ import { supabase } from "../../supabaseClient";
|
||||||
|
|
||||||
export const StopWordsPanel = () => {
|
export const StopWordsPanel = () => {
|
||||||
const [words, setWords] = React.useState([]);
|
const [words, setWords] = React.useState([]);
|
||||||
const [scope, setScope] = React.useState("everywhere");
|
|
||||||
const [newWord, setNewWord] = React.useState("");
|
const [newWord, setNewWord] = React.useState("");
|
||||||
const [isLoading, setIsLoading] = React.useState(true);
|
const [isLoading, setIsLoading] = React.useState(true);
|
||||||
const [isSavingScope, setIsSavingScope] = React.useState(false);
|
|
||||||
const [error, setError] = React.useState("");
|
const [error, setError] = React.useState("");
|
||||||
const [savingId, setSavingId] = React.useState(null);
|
const [deletingId, setDeletingId] = React.useState(null);
|
||||||
|
|
||||||
const loadWords = React.useCallback(async () => {
|
const loadWords = React.useCallback(async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
@ -28,18 +26,7 @@ export const StopWordsPanel = () => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadScope = React.useCallback(async () => {
|
React.useEffect(() => { loadWords(); }, [loadWords]);
|
||||||
const { data } = await supabase
|
|
||||||
.from("stop_words_scope")
|
|
||||||
.select("scope")
|
|
||||||
.eq("id", 1)
|
|
||||||
.single();
|
|
||||||
if (data) setScope(data.scope);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
Promise.all([loadWords(), loadScope()]);
|
|
||||||
}, [loadWords, loadScope]);
|
|
||||||
|
|
||||||
const handleAdd = async () => {
|
const handleAdd = async () => {
|
||||||
const trimmed = newWord.trim().toLowerCase();
|
const trimmed = newWord.trim().toLowerCase();
|
||||||
|
|
@ -61,7 +48,7 @@ export const StopWordsPanel = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id) => {
|
const handleDelete = async (id) => {
|
||||||
setSavingId(id);
|
setDeletingId(id);
|
||||||
const { error: deleteError } = await supabase
|
const { error: deleteError } = await supabase
|
||||||
.from("stop_words")
|
.from("stop_words")
|
||||||
.delete()
|
.delete()
|
||||||
|
|
@ -71,21 +58,7 @@ export const StopWordsPanel = () => {
|
||||||
} else {
|
} else {
|
||||||
await loadWords();
|
await loadWords();
|
||||||
}
|
}
|
||||||
setSavingId(null);
|
setDeletingId(null);
|
||||||
};
|
|
||||||
|
|
||||||
const handleScopeChange = async (newScope) => {
|
|
||||||
setIsSavingScope(true);
|
|
||||||
setError("");
|
|
||||||
const { error: upsertError } = await supabase
|
|
||||||
.from("stop_words_scope")
|
|
||||||
.upsert({ id: 1, scope: newScope }, { onConflict: "id" });
|
|
||||||
if (upsertError) {
|
|
||||||
setError(upsertError.message);
|
|
||||||
} else {
|
|
||||||
setScope(newScope);
|
|
||||||
}
|
|
||||||
setIsSavingScope(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e) => {
|
const handleKeyDown = (e) => {
|
||||||
|
|
@ -100,46 +73,8 @@ export const StopWordsPanel = () => {
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold">Стоп-слова</h2>
|
<h2 className="text-lg font-semibold">Стоп-слова</h2>
|
||||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||||
Позиции с этими словами скрываются из состава заказа.
|
Позиции с этими словами не показываются клиентам в карточке доставки.
|
||||||
</p>
|
Добавляйте слова-маркеры: «сверление», «обмер» и т.д.
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 space-y-3">
|
|
||||||
<p className="text-sm font-medium text-[var(--color-text)]">Где применять стоп-слова:</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={isSavingScope}
|
|
||||||
onClick={() => handleScopeChange("everywhere")}
|
|
||||||
className={[
|
|
||||||
"rounded-full border px-4 py-2 text-sm font-medium transition",
|
|
||||||
scope === "everywhere"
|
|
||||||
? "border-[var(--color-accent)] bg-[var(--color-accent)] text-[var(--color-accent-contrast)]"
|
|
||||||
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-text)]",
|
|
||||||
isSavingScope ? "opacity-50" : "",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
Везде
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={isSavingScope}
|
|
||||||
onClick={() => handleScopeChange("client_only")}
|
|
||||||
className={[
|
|
||||||
"rounded-full border px-4 py-2 text-sm font-medium transition",
|
|
||||||
scope === "client_only"
|
|
||||||
? "border-[var(--color-accent)] bg-[var(--color-accent)] text-[var(--color-accent-contrast)]"
|
|
||||||
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-text)]",
|
|
||||||
isSavingScope ? "opacity-50" : "",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
Только карточка клиента
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">
|
|
||||||
{scope === "everywhere"
|
|
||||||
? "Стоп-слова скрывают позиции и в панели управления, и в карточке клиента."
|
|
||||||
: "Стоп-слова скрывают позиции только на странице выбора времени доставки."}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -180,12 +115,12 @@ export const StopWordsPanel = () => {
|
||||||
{w.word}
|
{w.word}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={savingId === w.id}
|
disabled={deletingId === w.id}
|
||||||
onClick={() => handleDelete(w.id)}
|
onClick={() => handleDelete(w.id)}
|
||||||
className="ml-0.5 flex h-4 w-4 items-center justify-center rounded-full text-[var(--color-text-muted)] transition hover:bg-[var(--color-accent-soft)] hover:!text-[var(--color-danger)] disabled:opacity-40"
|
className="ml-0.5 flex h-4 w-4 items-center justify-center rounded-full text-[var(--color-text-muted)] transition hover:bg-[var(--color-accent-soft)] hover:!text-[var(--color-danger)] disabled:opacity-40"
|
||||||
aria-label={`Удалить ${w.word}`}
|
aria-label={`Удалить ${w.word}`}
|
||||||
>
|
>
|
||||||
✕
|
×
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1,259 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import { Panel } from "../UI/Panel";
|
|
||||||
import { useAuth } from "../../context/AuthContext";
|
|
||||||
|
|
||||||
const CATEGORY_OPTIONS = [
|
|
||||||
{ value: "feature", label: "Новая функция", icon: "✨" },
|
|
||||||
{ value: "improvement", label: "Улучшение", icon: "🔧" },
|
|
||||||
{ value: "bug", label: "Проблема", icon: "🐛" },
|
|
||||||
{ value: "other", label: "Другое", icon: "💬" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const STATUS_MAP = {
|
|
||||||
new: { label: "Новое", color: "#3b82f6" },
|
|
||||||
reviewed: { label: "Рассмотрено", color: "#f59e0b" },
|
|
||||||
accepted: { label: "Принято", color: "#22c55e" },
|
|
||||||
declined: { label: "Отклонено", color: "#ef4444" },
|
|
||||||
implemented: { label: "Реализовано", color: "#8b5cf6" },
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SuggestionsPanel = () => {
|
|
||||||
const { user } = useAuth();
|
|
||||||
const isAdmin = ["admin", "mega_admin"].includes(user?.role);
|
|
||||||
const [suggestions, setSuggestions] = React.useState([]);
|
|
||||||
const [loading, setLoading] = React.useState(true);
|
|
||||||
const [newText, setNewText] = React.useState("");
|
|
||||||
const [newCategory, setNewCategory] = React.useState("feature");
|
|
||||||
const [submitting, setSubmitting] = React.useState(false);
|
|
||||||
const [message, setMessage] = React.useState("");
|
|
||||||
|
|
||||||
const getSupabase = React.useCallback(async () => {
|
|
||||||
const { createClient } = await import("@supabase/supabase-js");
|
|
||||||
return createClient(
|
|
||||||
import.meta.env.VITE_SUPABASE_URL || window.__SUPABASE_URL__,
|
|
||||||
import.meta.env.VITE_SUPABASE_ANON_KEY || window.__SUPABASE_ANON_KEY__
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchSuggestions = React.useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const supabase = await getSupabase();
|
|
||||||
const { data } = await supabase
|
|
||||||
.from("suggestions")
|
|
||||||
.select("*")
|
|
||||||
.order("created_at", { ascending: false });
|
|
||||||
setSuggestions(data || []);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("fetch suggestions error", e);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [getSupabase]);
|
|
||||||
|
|
||||||
React.useEffect(() => { fetchSuggestions(); }, [fetchSuggestions]);
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!newText.trim()) return;
|
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
|
||||||
const supabase = await getSupabase();
|
|
||||||
const { error } = await supabase.from("suggestions").insert({
|
|
||||||
author_id: user?.id,
|
|
||||||
author_name: user?.name || user?.email || "Сотрудник",
|
|
||||||
author_role: user?.role || "unknown",
|
|
||||||
content: newText.trim(),
|
|
||||||
category: newCategory,
|
|
||||||
});
|
|
||||||
if (error) throw error;
|
|
||||||
setNewText("");
|
|
||||||
setMessage("✅ Предложение отправлено!");
|
|
||||||
fetchSuggestions();
|
|
||||||
setTimeout(() => setMessage(""), 3000);
|
|
||||||
} catch (e) {
|
|
||||||
setMessage("❌ Ошибка: " + e.message);
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStatusChange = async (id, newStatus, adminComment) => {
|
|
||||||
try {
|
|
||||||
const supabase = await getSupabase();
|
|
||||||
const updates = { status: newStatus, updated_at: new Date().toISOString() };
|
|
||||||
if (adminComment !== undefined) updates.admin_comment = adminComment;
|
|
||||||
const { error } = await supabase.from("suggestions").update(updates).eq("id", id);
|
|
||||||
if (error) throw error;
|
|
||||||
fetchSuggestions();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("update suggestion error", e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Panel className="space-y-4 p-5">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-xl">💡</span>
|
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-[0.16em] text-[var(--color-text)]">
|
|
||||||
Предложить улучшение
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">
|
|
||||||
Есть идея? Опишите — админы рассмотрят.
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{CATEGORY_OPTIONS.map((cat) => (
|
|
||||||
<button
|
|
||||||
key={cat.value}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setNewCategory(cat.value)}
|
|
||||||
className={[
|
|
||||||
"rounded-xl border px-3 py-1.5 text-xs font-medium transition",
|
|
||||||
newCategory === cat.value
|
|
||||||
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] !text-[var(--color-text)]"
|
|
||||||
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)]"
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
{cat.icon} {cat.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
value={newText}
|
|
||||||
onChange={(e) => setNewText(e.target.value)}
|
|
||||||
placeholder="Опишите предложение..."
|
|
||||||
rows={3}
|
|
||||||
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] px-4 py-3 text-sm text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)] focus:outline-none resize-none"
|
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!newText.trim() || submitting}
|
|
||||||
onClick={handleSubmit}
|
|
||||||
className="rounded-xl bg-[var(--color-accent)] px-5 py-2.5 text-sm font-semibold text-white transition hover:opacity-90 disabled:opacity-40"
|
|
||||||
>
|
|
||||||
{submitting ? "Отправка..." : "Отправить"}
|
|
||||||
</button>
|
|
||||||
{message && <span className="text-sm text-[var(--color-text-muted)]">{message}</span>}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<Panel className="space-y-3 p-5">
|
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-[0.16em] text-[var(--color-text)]">
|
|
||||||
Все предложения
|
|
||||||
</h2>
|
|
||||||
{loading ? (
|
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">Загрузка...</p>
|
|
||||||
) : suggestions.length === 0 ? (
|
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">Пока нет предложений</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{suggestions.map((s) => {
|
|
||||||
const cat = CATEGORY_OPTIONS.find((c) => c.value === s.category) || CATEGORY_OPTIONS[3];
|
|
||||||
const st = STATUS_MAP[s.status] || STATUS_MAP.new;
|
|
||||||
return (
|
|
||||||
<SuggestionCard
|
|
||||||
key={s.id}
|
|
||||||
suggestion={s}
|
|
||||||
category={cat}
|
|
||||||
status={st}
|
|
||||||
isAdmin={isAdmin}
|
|
||||||
onStatusChange={handleStatusChange}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Panel>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const SuggestionCard = ({ suggestion: s, category, status, isAdmin, onStatusChange }) => {
|
|
||||||
const [editing, setEditing] = React.useState(false);
|
|
||||||
const [comment, setComment] = React.useState(s.admin_comment || "");
|
|
||||||
|
|
||||||
const formatDate = (d) => {
|
|
||||||
if (!d) return "";
|
|
||||||
const date = new Date(d);
|
|
||||||
return date.toLocaleDateString("ru-RU", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-4 space-y-2">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-base">{category.icon}</span>
|
|
||||||
<span className="text-xs font-medium text-[var(--color-text-muted)]">{category.label}</span>
|
|
||||||
<span
|
|
||||||
className="rounded-lg px-2 py-0.5 text-[10px] font-semibold uppercase"
|
|
||||||
style={{ backgroundColor: status.color + "18", color: status.color }}
|
|
||||||
>
|
|
||||||
{status.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-[10px] text-[var(--color-text-muted)] whitespace-nowrap">{formatDate(s.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-[var(--color-text)] whitespace-pre-wrap">{s.content}</p>
|
|
||||||
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
|
|
||||||
<span className="font-medium">{s.author_name}</span>
|
|
||||||
<span>·</span>
|
|
||||||
<span>{s.author_role}</span>
|
|
||||||
</div>
|
|
||||||
{s.admin_comment && (
|
|
||||||
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] p-3 text-xs text-[var(--color-text-muted)]">
|
|
||||||
<span className="font-semibold">💬 Админ:</span> {s.admin_comment}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isAdmin && (
|
|
||||||
<div className="border-t border-[var(--color-border)] pt-2 mt-2 space-y-2">
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{Object.entries(STATUS_MAP).map(([key, val]) => (
|
|
||||||
<button
|
|
||||||
key={key}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onStatusChange(s.id, key)}
|
|
||||||
className={[
|
|
||||||
"rounded-lg px-2 py-1 text-[10px] font-semibold uppercase transition",
|
|
||||||
s.status === key ? "text-white" : "opacity-60 hover:opacity-100"
|
|
||||||
].join(" ")}
|
|
||||||
style={{
|
|
||||||
backgroundColor: s.status === key ? val.color : val.color + "22",
|
|
||||||
color: s.status === key ? "#fff" : val.color
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{val.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{editing ? (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={comment}
|
|
||||||
onChange={(e) => setComment(e.target.value)}
|
|
||||||
placeholder="Комментарий админа..."
|
|
||||||
className="flex-1 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-1.5 text-xs text-[var(--color-text)]"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => { onStatusChange(s.id, s.status, comment); setEditing(false); }}
|
|
||||||
className="rounded-lg bg-[var(--color-accent)] px-3 py-1.5 text-xs font-semibold text-white"
|
|
||||||
>
|
|
||||||
Сохранить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setEditing(true)}
|
|
||||||
className="text-xs text-[var(--color-accent)] hover:underline"
|
|
||||||
>
|
|
||||||
✏️ Комментарий
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -22,7 +22,6 @@ export const DeliveryChoiceFlow = ({
|
||||||
invitation = {},
|
invitation = {},
|
||||||
selectedSlot = null,
|
selectedSlot = null,
|
||||||
onConfirmChoice = () => {},
|
onConfirmChoice = () => {},
|
||||||
deliveryType = "delivery",
|
|
||||||
}) => {
|
}) => {
|
||||||
const state = invitation.state || "awaiting_choice";
|
const state = invitation.state || "awaiting_choice";
|
||||||
const isActive = ACTIVE_STATES.has(state);
|
const isActive = ACTIVE_STATES.has(state);
|
||||||
|
|
@ -37,22 +36,16 @@ export const DeliveryChoiceFlow = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const typeLabel = deliveryType === "pickup" ? "самовывоз" : "доставку";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel className="space-y-5 p-5 sm:p-6">
|
<Panel className="space-y-5 p-5 sm:p-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">
|
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Согласование доставки</p>
|
||||||
{deliveryType === "pickup" ? "Согласование самовывоза" : "Согласование доставки"}
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h1 className="text-2xl font-semibold leading-tight sm:text-3xl">
|
<h1 className="text-2xl font-semibold leading-tight sm:text-3xl">Выберите время доставки</h1>
|
||||||
{deliveryType === "pickup" ? "Выберите время самовывоза" : "Выберите время доставки"}
|
|
||||||
</h1>
|
|
||||||
<Badge tone="warning">{STATE_LABELS[state]}</Badge>
|
<Badge tone="warning">{STATE_LABELS[state]}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
{invitationReference}. Выберите удобную половину дня для {typeLabel}.
|
{invitationReference}. Выберите удобную половину дня.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,6 @@ import React from "react";
|
||||||
import { Badge } from "../UI/Badge";
|
import { Badge } from "../UI/Badge";
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
import { getInvitationReferenceLabel } from "./invitationReference";
|
import { getInvitationReferenceLabel } from "./invitationReference";
|
||||||
import { supabase } from "../../supabaseClient";
|
|
||||||
import { matchesStopWord } from "../../hooks/useStopWords";
|
|
||||||
|
|
||||||
const flattenOrderProducts = (rawItems) => {
|
const flattenOrderProducts = (rawItems) => {
|
||||||
if (!Array.isArray(rawItems) || rawItems.length === 0) return [];
|
if (!Array.isArray(rawItems) || rawItems.length === 0) return [];
|
||||||
|
|
@ -32,7 +30,6 @@ const flattenOrderProducts = (rawItems) => {
|
||||||
name: pName,
|
name: pName,
|
||||||
quantity: String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(),
|
quantity: String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(),
|
||||||
unit: String(p.product_ed || p.unit || "").trim(),
|
unit: String(p.product_ed || p.unit || "").trim(),
|
||||||
_sourceOrder: sub,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -45,7 +42,6 @@ const flattenOrderProducts = (rawItems) => {
|
||||||
name: pName,
|
name: pName,
|
||||||
quantity: String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(),
|
quantity: String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(),
|
||||||
unit: String(p.product_ed || p.unit || "").trim(),
|
unit: String(p.product_ed || p.unit || "").trim(),
|
||||||
_sourceOrder: item,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -58,34 +54,23 @@ const flattenOrderProducts = (rawItems) => {
|
||||||
name,
|
name,
|
||||||
quantity: String(item.product_quantity || item.quantity || item.count || item.amount || "").trim(),
|
quantity: String(item.product_quantity || item.quantity || item.count || item.amount || "").trim(),
|
||||||
unit: String(item.product_ed || item.unit || "").trim(),
|
unit: String(item.product_ed || item.unit || "").trim(),
|
||||||
_sourceOrder: item,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return products;
|
return products;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const matchesStopWord = (name, stopWords) => {
|
||||||
|
if (!stopWords || !stopWords.length) return false;
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
return stopWords.some((sw) => lower.includes(sw.toLowerCase()));
|
||||||
|
};
|
||||||
|
|
||||||
export const OrderCompositionPanel = ({ invitation = {} }) => {
|
export const OrderCompositionPanel = ({ invitation = {} }) => {
|
||||||
const [stopWords, setStopWords] = React.useState([]);
|
const stopWords = invitation.stopWords || [];
|
||||||
const [stopWordsLoaded, setStopWordsLoaded] = React.useState(false);
|
|
||||||
const [scopeActive, setScopeActive] = React.useState(true);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!supabase) { setStopWordsLoaded(true); return; }
|
|
||||||
Promise.all([
|
|
||||||
supabase.from("stop_words").select("word"),
|
|
||||||
supabase.from("stop_words_scope").select("scope").eq("id", 1).single(),
|
|
||||||
]).then(([{ data: wordsData }, { data: scopeData }]) => {
|
|
||||||
if (wordsData) setStopWords(wordsData.map((d) => d.word));
|
|
||||||
setScopeActive(scopeData?.scope === "everywhere" || scopeData?.scope === "client_only");
|
|
||||||
setStopWordsLoaded(true);
|
|
||||||
})
|
|
||||||
.catch(() => setStopWordsLoaded(true));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const rawItems = invitation.orderItems || invitation.items || [];
|
const rawItems = invitation.orderItems || invitation.items || [];
|
||||||
const allProducts = flattenOrderProducts(rawItems);
|
const allProducts = flattenOrderProducts(rawItems);
|
||||||
const products = (stopWords.length && scopeActive)
|
const products = stopWords.length
|
||||||
? allProducts.filter((p) => !matchesStopWord(p.name, stopWords))
|
? allProducts.filter((p) => !matchesStopWord(p.name, stopWords))
|
||||||
: allProducts;
|
: allProducts;
|
||||||
|
|
||||||
|
|
@ -94,8 +79,6 @@ export const OrderCompositionPanel = ({ invitation = {} }) => {
|
||||||
|
|
||||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||||
|
|
||||||
// Hide the entire panel if there are no products to show and some were filtered
|
|
||||||
if (products.length === 0 && filteredCount > 0) return null;
|
|
||||||
if (products.length === 0 && filteredCount === 0) return null;
|
if (products.length === 0 && filteredCount === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -135,6 +118,11 @@ export const OrderCompositionPanel = ({ invitation = {} }) => {
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{products.length === 0 && filteredCount > 0 && (
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
|
Все позиции исключены из отображения.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
|
||||||
|
|
@ -1,212 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import { Button } from "../UI/Button";
|
|
||||||
import { Panel } from "../UI/Panel";
|
|
||||||
import { formatDeliveryDate, getDeliveryRelativeDayLabel } from "./deliveryDateFormatting";
|
|
||||||
|
|
||||||
const DELIVERY_TIMEZONE = "Europe/Simferopol";
|
|
||||||
|
|
||||||
const getCrimeaTodayKey = (referenceDate = new Date()) => {
|
|
||||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
||||||
timeZone: DELIVERY_TIMEZONE,
|
|
||||||
year: "numeric",
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
}).formatToParts(referenceDate);
|
|
||||||
const year = parts.find((p) => p.type === "year")?.value || "";
|
|
||||||
const month = parts.find((p) => p.type === "month")?.value || "";
|
|
||||||
const day = parts.find((p) => p.type === "day")?.value || "";
|
|
||||||
return `${year}-${month}-${day}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const addDaysKey = (dateKey, amount) => {
|
|
||||||
const base = new Date(`${dateKey}T12:00:00Z`);
|
|
||||||
if (Number.isNaN(base.getTime())) return "";
|
|
||||||
base.setUTCDate(base.getUTCDate() + amount);
|
|
||||||
return base.toISOString().slice(0, 10);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCrimeaHour = (referenceDate = new Date()) => {
|
|
||||||
return parseInt(
|
|
||||||
new Intl.DateTimeFormat("ru-RU", {
|
|
||||||
timeZone: DELIVERY_TIMEZONE,
|
|
||||||
hour: "numeric",
|
|
||||||
hour12: false,
|
|
||||||
}).format(referenceDate),
|
|
||||||
10
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isWeekend = (dateKey) => {
|
|
||||||
const d = new Date(`${dateKey}T12:00:00Z`);
|
|
||||||
const day = d.getUTCDay();
|
|
||||||
return day === 0 || day === 6;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getNextWorkday = (dateKey) => {
|
|
||||||
let next = addDaysKey(dateKey, 1);
|
|
||||||
while (isWeekend(next)) {
|
|
||||||
next = addDaysKey(next, 1);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPickupSlots = (referenceDate = new Date()) => {
|
|
||||||
const todayKey = getCrimeaTodayKey(referenceDate);
|
|
||||||
const hour = getCrimeaHour(referenceDate);
|
|
||||||
const isTodayWorkday = !isWeekend(todayKey);
|
|
||||||
|
|
||||||
const slots = [];
|
|
||||||
|
|
||||||
if (isTodayWorkday && hour < 12) {
|
|
||||||
slots.push({
|
|
||||||
id: `pickup-${todayKey}-first`,
|
|
||||||
date: todayKey,
|
|
||||||
time: "Первая половина дня",
|
|
||||||
label: "Сегодня",
|
|
||||||
pickupType: "today",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const tomorrow = addDaysKey(todayKey, 1);
|
|
||||||
const tomorrowWorkday = !isWeekend(tomorrow) ? tomorrow : getNextWorkday(todayKey);
|
|
||||||
slots.push({
|
|
||||||
id: `pickup-${tomorrowWorkday}-first`,
|
|
||||||
date: tomorrowWorkday,
|
|
||||||
time: "Первая половина дня",
|
|
||||||
label: getDeliveryRelativeDayLabel(tomorrowWorkday, referenceDate) || "Завтра",
|
|
||||||
pickupType: "tomorrow",
|
|
||||||
});
|
|
||||||
slots.push({
|
|
||||||
id: `pickup-${tomorrowWorkday}-second`,
|
|
||||||
date: tomorrowWorkday,
|
|
||||||
time: "Вторая половина дня",
|
|
||||||
label: getDeliveryRelativeDayLabel(tomorrowWorkday, referenceDate) || "Завтра",
|
|
||||||
pickupType: "tomorrow",
|
|
||||||
});
|
|
||||||
|
|
||||||
const dayAfter = addDaysKey(tomorrowWorkday, 1);
|
|
||||||
const dayAfterWorkday = !isWeekend(dayAfter) ? dayAfter : getNextWorkday(dayAfter);
|
|
||||||
slots.push({
|
|
||||||
id: `pickup-${dayAfterWorkday}-first`,
|
|
||||||
date: dayAfterWorkday,
|
|
||||||
time: "Первая половина дня",
|
|
||||||
label: getDeliveryRelativeDayLabel(dayAfterWorkday, referenceDate) || "Послезавтра",
|
|
||||||
pickupType: "dayAfter",
|
|
||||||
});
|
|
||||||
slots.push({
|
|
||||||
id: `pickup-${dayAfterWorkday}-second`,
|
|
||||||
date: dayAfterWorkday,
|
|
||||||
time: "Вторая половина дня",
|
|
||||||
label: getDeliveryRelativeDayLabel(dayAfterWorkday, referenceDate) || "Послезавтра",
|
|
||||||
pickupType: "dayAfter",
|
|
||||||
});
|
|
||||||
|
|
||||||
return slots;
|
|
||||||
};
|
|
||||||
|
|
||||||
const FREE_STORAGE_NOTICE = (
|
|
||||||
<div
|
|
||||||
className="mt-4 overflow-hidden rounded-[28px] border border-[var(--color-border)]"
|
|
||||||
style={{ background: "linear-gradient(135deg, var(--color-accent-soft) 0%, var(--color-surface) 60%)" }}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3 p-5 sm:p-6">
|
|
||||||
<span
|
|
||||||
className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-2xl text-lg"
|
|
||||||
style={{ background: "var(--color-accent)", color: "var(--color-accent-contrast)" }}
|
|
||||||
>
|
|
||||||
📦
|
|
||||||
</span>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-sm font-bold tracking-wide uppercase text-[var(--color-accent)]">
|
|
||||||
Условия хранения
|
|
||||||
</p>
|
|
||||||
<ul className="mt-2 space-y-1.5 text-sm leading-6 text-[var(--color-text)]">
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="mt-1.5 block h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--color-accent)]" />
|
|
||||||
<span>
|
|
||||||
Бесплатное хранение — <strong>2 рабочих дня</strong> с даты готовности
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="mt-1.5 block h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--color-warning)]" />
|
|
||||||
<span>
|
|
||||||
С 3-го рабочего дня — <strong>300 ₽/день</strong> платного хранения
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const PickupSlotsPicker = ({
|
|
||||||
onSelectSlot,
|
|
||||||
selectedSlotId,
|
|
||||||
referenceDate = new Date(),
|
|
||||||
}) => {
|
|
||||||
const slots = React.useMemo(() => getPickupSlots(referenceDate), [referenceDate]);
|
|
||||||
|
|
||||||
if (!slots.length) {
|
|
||||||
return (
|
|
||||||
<Panel className="p-5 sm:p-6">
|
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">
|
|
||||||
Нет доступных слотов для самовывоза.
|
|
||||||
</p>
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const grouped = React.useMemo(() => {
|
|
||||||
const map = new Map();
|
|
||||||
for (const slot of slots) {
|
|
||||||
if (!map.has(slot.date)) map.set(slot.date, []);
|
|
||||||
map.get(slot.date).push(slot);
|
|
||||||
}
|
|
||||||
return Array.from(map.entries());
|
|
||||||
}, [slots]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{grouped.map(([date, dateSlots]) => (
|
|
||||||
<details
|
|
||||||
key={date}
|
|
||||||
className="group rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-soft backdrop-blur"
|
|
||||||
open
|
|
||||||
>
|
|
||||||
<summary className="cursor-pointer list-none p-5 sm:p-6">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<h4 className="font-medium">
|
|
||||||
Самовывоз{" "}
|
|
||||||
{dateSlots[0]?.label
|
|
||||||
? `${dateSlots[0].label.charAt(0).toLowerCase()}${dateSlots[0].label.slice(1)}`
|
|
||||||
: ""}{" "}
|
|
||||||
· {formatDeliveryDate(date)}
|
|
||||||
</h4>
|
|
||||||
<span className="text-sm text-[var(--color-text-muted)] group-open:hidden">Раскрыть</span>
|
|
||||||
<span className="hidden text-sm text-[var(--color-text-muted)] group-open:inline">Свернуть</span>
|
|
||||||
</div>
|
|
||||||
</summary>
|
|
||||||
<div className="px-5 pb-5 sm:px-6 sm:pb-6">
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
{dateSlots.map((slot) => {
|
|
||||||
const isSelected = selectedSlotId === slot.id;
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={slot.id}
|
|
||||||
variant={isSelected ? "primary" : "secondary"}
|
|
||||||
aria-pressed={isSelected}
|
|
||||||
onClick={() => onSelectSlot(slot)}
|
|
||||||
>
|
|
||||||
{slot.time}
|
|
||||||
{isSelected ? " — Выбрано" : ""}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
))}
|
|
||||||
{FREE_STORAGE_NOTICE}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { supabase } from "../../supabaseClient";
|
|
||||||
import { Badge } from "../UI/Badge";
|
import { Badge } from "../UI/Badge";
|
||||||
import { Button } from "../UI/Button";
|
import { Button } from "../UI/Button";
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
import { matchesStopWord } from "../../hooks/useStopWords";
|
|
||||||
|
|
||||||
const parseOrderItems = (order) => {
|
const parseOrderItems = (order) => {
|
||||||
if (!order) return [];
|
if (!order) return [];
|
||||||
|
|
@ -90,25 +88,7 @@ const parseOrderItems = (order) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DriverShipmentPanel = ({ order, onShipmentChange }) => {
|
export const DriverShipmentPanel = ({ order, onShipmentChange }) => {
|
||||||
const [stopWords, setStopWords] = React.useState([]);
|
const items = React.useMemo(() => parseOrderItems(order), [order]);
|
||||||
const [scopeActive, setScopeActive] = React.useState(true);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!supabase) return;
|
|
||||||
Promise.all([
|
|
||||||
supabase.from("stop_words").select("word"),
|
|
||||||
supabase.from("stop_words_scope").select("scope").eq("id", 1).single(),
|
|
||||||
]).then(([{ data: wordsData }, { data: scopeData }]) => {
|
|
||||||
if (wordsData) setStopWords(wordsData.map((d) => d.word));
|
|
||||||
setScopeActive(scopeData?.scope === "everywhere" || scopeData?.scope === "client_only");
|
|
||||||
}).catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const allItems = React.useMemo(() => parseOrderItems(order), [order]);
|
|
||||||
const items = React.useMemo(() => {
|
|
||||||
if (!stopWords.length || !scopeActive) return allItems;
|
|
||||||
return allItems.filter((item) => !matchesStopWord(item.name, stopWords));
|
|
||||||
}, [allItems, stopWords, scopeActive]);
|
|
||||||
const [shippedItems, setShippedItems] = React.useState(new Set());
|
const [shippedItems, setShippedItems] = React.useState(new Set());
|
||||||
const [comments, setComments] = React.useState({});
|
const [comments, setComments] = React.useState({});
|
||||||
const [commentInput, setCommentInput] = React.useState("");
|
const [commentInput, setCommentInput] = React.useState("");
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { Button } from "../UI/Button";
|
import { Button } from "../UI/Button";
|
||||||
import { OrderDetailPanel } from "../orders/OrderDetailPanel";
|
import { OrderDetailPanel } from "../orders/OrderDetailPanel";
|
||||||
import ErrorBoundary from "../ErrorBoundary";
|
|
||||||
|
|
||||||
export const DeliverySetDetailPanel = ({ deliverySet, onClose }) => {
|
export const DeliverySetDetailPanel = ({ deliverySet, onClose }) => {
|
||||||
if (!deliverySet) {
|
if (!deliverySet) {
|
||||||
|
|
@ -9,9 +8,7 @@ export const DeliverySetDetailPanel = ({ deliverySet, onClose }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<ErrorBoundary compact>
|
<OrderDetailPanel order={deliverySet} />
|
||||||
<OrderDetailPanel order={deliverySet} />
|
|
||||||
</ErrorBoundary>
|
|
||||||
|
|
||||||
{onClose ? (
|
{onClose ? (
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
|
|
|
||||||
|
|
@ -1,185 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
const WEEK_DAY_LABELS = ["ПН", "ВТ", "СР", "ЧТ", "ПТ", "СБ", "ВС"];
|
|
||||||
|
|
||||||
const toDateKey = (date) => {
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
||||||
const day = String(date.getDate()).padStart(2, "0");
|
|
||||||
return `${year}-${month}-${day}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isWeekendDate = (date) => {
|
|
||||||
const day = date.getDay();
|
|
||||||
return day === 0 || day === 6;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isSelectableCalendarDate = (date, minDateKey) => {
|
|
||||||
const dateKey = toDateKey(date);
|
|
||||||
return dateKey >= minDateKey && !isWeekendDate(date);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDateForDisplay = (value) => {
|
|
||||||
if (!value) {
|
|
||||||
return "Выберите дату";
|
|
||||||
}
|
|
||||||
|
|
||||||
const [year, month, day] = value.split("-").map(Number);
|
|
||||||
if (!year || !month || !day) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Date(year, month - 1, day).toLocaleDateString("ru-RU", {
|
|
||||||
day: "2-digit",
|
|
||||||
month: "2-digit",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const CalendarWidget = ({
|
|
||||||
label,
|
|
||||||
selectedDate,
|
|
||||||
onDateChange,
|
|
||||||
minDateKey,
|
|
||||||
isCalendarOpen,
|
|
||||||
setIsCalendarOpen,
|
|
||||||
currentMonth,
|
|
||||||
setCurrentMonth,
|
|
||||||
calendarDays,
|
|
||||||
monthLabel,
|
|
||||||
canGoBack,
|
|
||||||
timeOptions,
|
|
||||||
selectedTime,
|
|
||||||
onTimeChange,
|
|
||||||
layoutClassName,
|
|
||||||
calendarClassName,
|
|
||||||
timeClassName,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className={layoutClassName}>
|
|
||||||
<div className={calendarClassName}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={label}
|
|
||||||
aria-expanded={isCalendarOpen}
|
|
||||||
className="flex min-h-[54px] w-full items-center justify-between rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 text-left text-sm font-medium !text-[var(--color-text)] transition hover:border-[var(--color-accent)] focus:border-[var(--color-accent)] focus:outline-none"
|
|
||||||
onClick={() => setIsCalendarOpen((current) => !current)}
|
|
||||||
>
|
|
||||||
<span>{selectedDate ? formatDateForDisplay(selectedDate) : "Выберите дату"}</span>
|
|
||||||
<span aria-hidden="true" className="text-[var(--color-text-muted)]">▾</span>
|
|
||||||
</button>
|
|
||||||
{isCalendarOpen ? (
|
|
||||||
<div className="rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-3 shadow-lg absolute left-0 top-full z-50 mt-2 w-[300px]">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
|
||||||
{label}
|
|
||||||
</p>
|
|
||||||
<h4
|
|
||||||
className="mt-1 text-base font-semibold capitalize"
|
|
||||||
style={{ color: "var(--color-text)" }}
|
|
||||||
>
|
|
||||||
{monthLabel}
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!canGoBack}
|
|
||||||
aria-label="Предыдущий месяц"
|
|
||||||
className="flex h-9 w-9 items-center justify-center rounded-full border border-[var(--color-border)] text-sm text-[var(--color-text-muted)] transition hover:border-[var(--color-accent)] hover:!text-[var(--color-text)] disabled:cursor-not-allowed disabled:opacity-40"
|
|
||||||
onClick={() => setCurrentMonth((month) => new Date(month.getFullYear(), month.getMonth() - 1, 1))}
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Следующий месяц"
|
|
||||||
className="flex h-9 w-9 items-center justify-center rounded-full border border-[var(--color-border)] text-sm text-[var(--color-text-muted)] transition hover:border-[var(--color-accent)] hover:!text-[var(--color-text)]"
|
|
||||||
onClick={() => setCurrentMonth((month) => new Date(month.getFullYear(), month.getMonth() + 1, 1))}
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 grid grid-cols-7 gap-1 text-center text-[10px] font-semibold uppercase text-[var(--color-text-muted)]">
|
|
||||||
{WEEK_DAY_LABELS.map((day) => (
|
|
||||||
<div key={day} className="px-1 py-1">
|
|
||||||
{day}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 grid grid-cols-7 gap-1">
|
|
||||||
{calendarDays.map((day, index) => {
|
|
||||||
if (!day) {
|
|
||||||
return <div key={`empty-${index}`} className="aspect-square" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dateKey = toDateKey(day);
|
|
||||||
const isWeekend = isWeekendDate(day);
|
|
||||||
const isSelectable = isSelectableCalendarDate(day, minDateKey);
|
|
||||||
const isSelected = dateKey === selectedDate;
|
|
||||||
const isDisabled = !isSelectable;
|
|
||||||
const dayNumber = String(day.getDate()).padStart(2, "0");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={dateKey}
|
|
||||||
type="button"
|
|
||||||
disabled={isDisabled}
|
|
||||||
title={isWeekend ? "Выходной, доставки нет" : isSelectable ? "Можно выбрать" : "Недоступно"}
|
|
||||||
className={[
|
|
||||||
"relative flex aspect-square items-center justify-center rounded-xl border text-sm font-semibold transition",
|
|
||||||
isSelected
|
|
||||||
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] !text-[var(--color-text)]"
|
|
||||||
: isWeekend
|
|
||||||
? "border-dashed border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)]"
|
|
||||||
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:!text-[var(--color-text)]",
|
|
||||||
isDisabled ? "cursor-not-allowed opacity-45" : "",
|
|
||||||
].join(" ")}
|
|
||||||
onClick={() => {
|
|
||||||
if (isDisabled) return;
|
|
||||||
onDateChange(dateKey);
|
|
||||||
setIsCalendarOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>{dayNumber}</span>
|
|
||||||
{isWeekend ? (
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
className="absolute inset-x-2 top-1/2 h-px -rotate-12 bg-[var(--color-text-muted)] opacity-70"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-xs text-[var(--color-text-muted)]">
|
|
||||||
Выходные отмечены пунктиром и недоступны.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className={timeClassName}>
|
|
||||||
{timeOptions.map((option) => (
|
|
||||||
<button
|
|
||||||
key={option}
|
|
||||||
type="button"
|
|
||||||
aria-pressed={selectedTime === option}
|
|
||||||
className={[
|
|
||||||
"min-h-[54px] rounded-2xl border px-4 text-left text-sm font-medium transition",
|
|
||||||
selectedTime === option
|
|
||||||
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] !text-[var(--color-text)]"
|
|
||||||
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:!text-[var(--color-text)]",
|
|
||||||
].join(" ")}
|
|
||||||
onClick={() => onTimeChange(option)}
|
|
||||||
>
|
|
||||||
{option}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { CalendarWidget, formatDateForDisplay };
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import { Badge } from "../UI/Badge";
|
|
||||||
import { Button } from "../UI/Button";
|
|
||||||
import { Select } from "../UI/Select";
|
|
||||||
import { Panel } from "../UI/Panel";
|
|
||||||
|
|
||||||
const DriverAssignmentPanel = ({
|
|
||||||
order,
|
|
||||||
userRole,
|
|
||||||
canManageDelivery,
|
|
||||||
isSavingDriverAssignment,
|
|
||||||
selectedDriverId,
|
|
||||||
onDriverSelect,
|
|
||||||
onConfirmDriver,
|
|
||||||
driverMessage,
|
|
||||||
drivers,
|
|
||||||
}) => {
|
|
||||||
if (!canManageDelivery || !["manager", "logistician", "admin", "mega_admin"].includes(userRole) || !order) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isPickupOrder = order.deliveryType === "pickup" || order.deliveryStatus === "pickup" || order.delivery_status === "pickup";
|
|
||||||
if (isPickupOrder) return null;
|
|
||||||
|
|
||||||
const ds = order.deliveryStatus || order.delivery_status;
|
|
||||||
const isDriverLocked = ["loaded", "on_route", "delivered"].includes(ds);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Panel className="space-y-4 p-5">
|
|
||||||
<div>
|
|
||||||
<strong>Назначение водителя</strong>
|
|
||||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
|
||||||
{(() => {
|
|
||||||
if (["loaded", "on_route", "delivered"].includes(ds)) {
|
|
||||||
return "Доставка в процессе — сменить водителя нельзя.";
|
|
||||||
}
|
|
||||||
return order.assignedDriverId
|
|
||||||
? "Назначен водитель. Вы можете изменить назначение."
|
|
||||||
: "Выберите водителя для доставки.";
|
|
||||||
})()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{order.assignedDriverId ? (
|
|
||||||
<div className="rounded-[24px] border border-[rgba(59,130,246,0.35)] bg-[var(--color-accent-soft)] p-4 !text-[var(--color-text)]">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-accent)]">
|
|
||||||
Водитель назначен
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-lg font-semibold">
|
|
||||||
{order.assignedDriverName || "Неизвестно"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Badge tone="accent">Назначен</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{!isDriverLocked ? (
|
|
||||||
<div className="grid gap-3 md:grid-cols-[minmax(16rem,24rem)_auto]">
|
|
||||||
<Select
|
|
||||||
className="h-[46px] py-0"
|
|
||||||
value={selectedDriverId}
|
|
||||||
onChange={(e) => {
|
|
||||||
onDriverSelect(e.target.value);
|
|
||||||
}}
|
|
||||||
disabled={isSavingDriverAssignment}
|
|
||||||
>
|
|
||||||
<option value="">{order.assignedDriverId ? "Сменить водителя..." : "Выберите водителя..."}</option>
|
|
||||||
{drivers.map((driver) => (
|
|
||||||
<option key={driver.id} value={driver.id}>{driver.name || driver.email}</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
<Button
|
|
||||||
className="md:px-4 md:py-2 md:whitespace-nowrap md:self-start"
|
|
||||||
onClick={onConfirmDriver}
|
|
||||||
disabled={isSavingDriverAssignment || !selectedDriverId}
|
|
||||||
>
|
|
||||||
{isSavingDriverAssignment ? "Назначаем..." : "Назначить"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{driverMessage ? (
|
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">{driverMessage}</p>
|
|
||||||
) : null}
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { DriverAssignmentPanel };
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -40,7 +40,7 @@ describe("OrderDetailPanel", () => {
|
||||||
<OrderDetailPanel order={order} />,
|
<OrderDetailPanel order={order} />,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(markup).toContain("Карточка группы");
|
expect(markup).toContain("Карточка группы доставки");
|
||||||
expect(markup).toContain("Мария Волкова");
|
expect(markup).toContain("Мария Волкова");
|
||||||
expect(markup).toContain("Адрес доставки");
|
expect(markup).toContain("Адрес доставки");
|
||||||
expect(markup).toContain("Симферополь, ул. Ленина, 10");
|
expect(markup).toContain("Симферополь, ул. Ленина, 10");
|
||||||
|
|
@ -109,22 +109,4 @@ describe("OrderDetailPanel", () => {
|
||||||
it("skips weekends when selecting the default manual delivery date", () => {
|
it("skips weekends when selecting the default manual delivery date", () => {
|
||||||
expect(getNextSelectableDateKey(new Date("2026-05-15T12:00:00Z"))).toBe("2026-05-18");
|
expect(getNextSelectableDateKey(new Date("2026-05-15T12:00:00Z"))).toBe("2026-05-18");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows pickup labels for pickup orders", () => {
|
|
||||||
const markup = renderToStaticMarkup(
|
|
||||||
<OrderDetailPanel
|
|
||||||
order={{
|
|
||||||
...order,
|
|
||||||
deliveryType: "pickup",
|
|
||||||
deliveryStatus: "pickup",
|
|
||||||
deliveryAddress: "",
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(markup).toContain("Карточка группы самовывоза");
|
|
||||||
expect(markup).toContain("Самовывоз");
|
|
||||||
expect(markup).toContain("Дата самовывоза");
|
|
||||||
expect(markup).toContain("Статус самовывоза");
|
|
||||||
expect(markup).not.toContain("Адрес доставки");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,20 +8,11 @@ import {
|
||||||
getOrderGroupStatusTone,
|
getOrderGroupStatusTone,
|
||||||
} from "../../services/orderGroupViews";
|
} from "../../services/orderGroupViews";
|
||||||
|
|
||||||
const MAX_VISIBLE_INVOICES = 2;
|
|
||||||
|
|
||||||
const fmtDate = (d) => {
|
|
||||||
if (!d) return '';
|
|
||||||
const [y, m, day] = d.split('-');
|
|
||||||
if (!y || !m || !day) return d;
|
|
||||||
return `${day}.${m}.${y}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildGroupSummary = (group) => {
|
const buildGroupSummary = (group) => {
|
||||||
const orderCountLabel = `${group.ordersCount || 0} ${group.ordersCount === 1 ? "заказ" : group.ordersCount < 5 ? "заказа" : "заказов"}`;
|
const orderCountLabel = `${group.ordersCount || 0} ${group.ordersCount === 1 ? "заказ" : group.ordersCount < 5 ? "заказа" : "заказов"}`;
|
||||||
const parts = [orderCountLabel];
|
const parts = [orderCountLabel];
|
||||||
if (group.deliveryDate) {
|
if (group.deliveryDate) {
|
||||||
const datePart = group.deliveryTime ? `${fmtDate(group.deliveryDate)} · ${group.deliveryTime}` : fmtDate(group.deliveryDate);
|
const datePart = group.deliveryTime ? `${group.deliveryDate} · ${group.deliveryTime}` : group.deliveryDate;
|
||||||
parts.push(datePart);
|
parts.push(datePart);
|
||||||
}
|
}
|
||||||
if (group.assignedDriverName) {
|
if (group.assignedDriverName) {
|
||||||
|
|
@ -32,36 +23,11 @@ const buildGroupSummary = (group) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderOrderNumbers = (group) => {
|
const renderOrderNumbers = (group) => {
|
||||||
const numbers = group.allBillNumbers || group.orderNumbers;
|
if (!Array.isArray(group.orderNumbers) || !group.orderNumbers.length) {
|
||||||
if (!Array.isArray(numbers) || !numbers.length) {
|
|
||||||
return "Номера не указаны";
|
return "Номера не указаны";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (numbers.length <= MAX_VISIBLE_INVOICES) {
|
return group.orderNumbers.slice(0, 3).join(" · ");
|
||||||
return numbers.join(", ");
|
|
||||||
}
|
|
||||||
const visible = numbers.slice(0, MAX_VISIBLE_INVOICES);
|
|
||||||
const remaining = numbers.length - MAX_VISIBLE_INVOICES;
|
|
||||||
return `${visible.join(", ")} +${remaining}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderMobileOrderNumbers = (group) => {
|
|
||||||
const numbers = group.allBillNumbers || group.orderNumbers;
|
|
||||||
if (!Array.isArray(numbers) || !numbers.length) {
|
|
||||||
return "Номера не указаны";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (numbers.length <= MAX_VISIBLE_INVOICES) {
|
|
||||||
return numbers.join(", ");
|
|
||||||
}
|
|
||||||
const visible = numbers.slice(0, MAX_VISIBLE_INVOICES);
|
|
||||||
const remaining = numbers.length - MAX_VISIBLE_INVOICES;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{visible.join(", ")}
|
|
||||||
<span className="ml-1 rounded-full bg-[var(--color-accent-soft)] px-1.5 py-0.5 text-xs font-medium text-[var(--color-accent)]">+{remaining}</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const OrdersTable = ({
|
export const OrdersTable = ({
|
||||||
|
|
@ -127,7 +93,7 @@ export const OrdersTable = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 text-sm text-[var(--color-text-muted)]">{buildGroupSummary(group)}</div>
|
<div className="mt-3 text-sm text-[var(--color-text-muted)]">{buildGroupSummary(group)}</div>
|
||||||
<div className="mt-2 text-sm text-[var(--color-text-muted)]">{renderMobileOrderNumbers(group)}</div>
|
<div className="mt-2 text-sm text-[var(--color-text-muted)]">{renderOrderNumbers(group)}</div>
|
||||||
<div className="mt-3 text-xs text-[var(--color-text-muted)]">
|
<div className="mt-3 text-xs text-[var(--color-text-muted)]">
|
||||||
{formatDateTime(group.updatedAt)}
|
{formatDateTime(group.updatedAt)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -144,8 +110,9 @@ export const OrdersTable = ({
|
||||||
<table className="min-w-full border-collapse">
|
<table className="min-w-full border-collapse">
|
||||||
<thead className="bg-[var(--color-surface-strong)] text-left text-xs uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
|
<thead className="bg-[var(--color-surface-strong)] text-left text-xs uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-5 py-4 font-medium">Группа / Клиент</th>
|
<th className="px-5 py-4 font-medium">Группа</th>
|
||||||
<th className="px-5 py-4 font-medium">Счёта</th>
|
<th className="px-5 py-4 font-medium">Клиент</th>
|
||||||
|
<th className="px-5 py-4 font-medium">Номера</th>
|
||||||
<th className="px-5 py-4 font-medium">Статус</th>
|
<th className="px-5 py-4 font-medium">Статус</th>
|
||||||
<th className="px-5 py-4 font-medium">Водитель</th>
|
<th className="px-5 py-4 font-medium">Водитель</th>
|
||||||
<th className="px-5 py-4 font-medium">Дата доставки</th>
|
<th className="px-5 py-4 font-medium">Дата доставки</th>
|
||||||
|
|
@ -164,15 +131,18 @@ export const OrdersTable = ({
|
||||||
>
|
>
|
||||||
<td className="px-5 py-4">
|
<td className="px-5 py-4">
|
||||||
<div className="font-medium">{group.displayTitle || group.customerName || group.groupKey}</div>
|
<div className="font-medium">{group.displayTitle || group.customerName || group.groupKey}</div>
|
||||||
<div className="mt-1 text-sm text-[var(--color-text-muted)]">
|
<div className="mt-1 text-sm text-[var(--color-text-muted)]">{group.groupKey}</div>
|
||||||
{[group.customerName, group.customerPhone].filter(Boolean).join(" · ")}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-[var(--color-text-muted)]">{group.groupKey}</div>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="max-w-[260px] px-5 py-4 text-sm text-[var(--color-text-muted)]">
|
<td className="px-5 py-4 text-sm">
|
||||||
|
<div>{group.customerName}</div>
|
||||||
|
<div className="mt-1 text-[var(--color-text-muted)]">
|
||||||
|
{group.customerPhone} · {group.customerDate}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="max-w-[340px] px-5 py-4 text-sm text-[var(--color-text-muted)]">
|
||||||
{renderOrderNumbers(group)}
|
{renderOrderNumbers(group)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-4 text-center">
|
<td className="px-5 py-4">
|
||||||
<Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupDisplayStatusLabel(group)}</Badge>
|
<Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupDisplayStatusLabel(group)}</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-4 text-sm">
|
<td className="px-5 py-4 text-sm">
|
||||||
|
|
@ -180,7 +150,7 @@ export const OrdersTable = ({
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-4 text-sm">
|
<td className="px-5 py-4 text-sm">
|
||||||
{group.deliveryDate ? (
|
{group.deliveryDate ? (
|
||||||
<span>{fmtDate(group.deliveryDate)}{group.deliveryTime ? <span className="text-[var(--color-text-muted)]"> · {group.deliveryTime}</span> : ""}</span>
|
<span>{group.deliveryDate}{group.deliveryTime ? <span className="text-[var(--color-text-muted)]"> · {group.deliveryTime}</span> : ""}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[var(--color-text-muted)]">—</span>
|
<span className="text-[var(--color-text-muted)]">—</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -196,4 +166,4 @@ export const OrdersTable = ({
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import { Badge } from "../UI/Badge";
|
|
||||||
import { Button } from "../UI/Button";
|
|
||||||
import { Panel } from "../UI/Panel";
|
|
||||||
import { DELIVERY_GROUP_STATUS_LABELS } from "../../services/orderGroupViews";
|
|
||||||
|
|
||||||
const STATUS_LABELS = DELIVERY_GROUP_STATUS_LABELS;
|
|
||||||
|
|
||||||
const StatusActionPanel = ({
|
|
||||||
order,
|
|
||||||
userRole,
|
|
||||||
canManageDelivery,
|
|
||||||
isSavingStatusChange,
|
|
||||||
onConfirmStatus,
|
|
||||||
}) => {
|
|
||||||
if (!canManageDelivery || !["manager", "logistician", "admin", "mega_admin"].includes(userRole) || !order) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentStatus = order.deliveryStatus || order.delivery_status;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Panel className="space-y-4 p-5">
|
|
||||||
<div>
|
|
||||||
<strong>Статус доставки</strong>
|
|
||||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
|
||||||
Измените статус, если водитель забыл обновить или нужна корректировка.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{[
|
|
||||||
{ value: "pending_confirmation", label: "Ожидает согласования", manual: true },
|
|
||||||
{ value: "agreed", label: "Согласовано", manual: false, hint: "Согласуйте дату доставки выше" },
|
|
||||||
{ value: "driver_assigned", label: "Назначен водитель", manual: false, hint: "Назначьте водителя из списка" },
|
|
||||||
{ value: "loaded", label: "Загружено", manual: true },
|
|
||||||
{ value: "on_route", label: "В пути", manual: true },
|
|
||||||
{ value: "delivered", label: "Доставлено", manual: true },
|
|
||||||
{ value: "pickup", label: "Самовывоз", manual: true },
|
|
||||||
{ value: "requires_address", label: "Требуется адрес", manual: true },
|
|
||||||
{ value: "problem", label: "Проблема", manual: true },
|
|
||||||
{ value: "cancelled", label: "Отменено", manual: true },
|
|
||||||
].map((statusOption) => {
|
|
||||||
const isCurrent = currentStatus === statusOption.value;
|
|
||||||
const isClickable = statusOption.manual !== false && !isCurrent;
|
|
||||||
return (
|
|
||||||
<div key={statusOption.value} className="relative group">
|
|
||||||
<Button
|
|
||||||
variant={isCurrent ? "primary" : "secondary"}
|
|
||||||
onClick={() => {
|
|
||||||
if (!isClickable) {
|
|
||||||
onConfirmStatus?.({ type: "hint", hint: statusOption.hint || "" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onConfirmStatus?.({ type: "status", status: statusOption.value });
|
|
||||||
}}
|
|
||||||
disabled={isSavingStatusChange}
|
|
||||||
>
|
|
||||||
{statusOption.label}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { StatusActionPanel };
|
|
||||||
|
|
@ -100,24 +100,6 @@ export const ORDER_STATUS_META = {
|
||||||
criticalAfterHours: 24,
|
criticalAfterHours: 24,
|
||||||
tone: "accent",
|
tone: "accent",
|
||||||
},
|
},
|
||||||
"Самовывоз": {
|
|
||||||
comment: "Клиент выбрал самовывоз. Заказ ожидает выдачи на складе.",
|
|
||||||
ownerRole: "logistician",
|
|
||||||
stageKey: "logistics",
|
|
||||||
stageLabel: getStageLabel("logistics"),
|
|
||||||
warningAfterHours: 24,
|
|
||||||
criticalAfterHours: 48,
|
|
||||||
tone: "accent",
|
|
||||||
},
|
|
||||||
"Требуется адрес": {
|
|
||||||
comment: "Клиент выбрал доставку, но адрес доставки отсутствует. Менеджеру нужно уточнить адрес.",
|
|
||||||
ownerRole: "logistician",
|
|
||||||
stageKey: "logistics",
|
|
||||||
stageLabel: getStageLabel("logistics"),
|
|
||||||
warningAfterHours: 4,
|
|
||||||
criticalAfterHours: 12,
|
|
||||||
tone: "warning",
|
|
||||||
},
|
|
||||||
"Передан логисту": {
|
"Передан логисту": {
|
||||||
comment: "Автоматическое согласование не завершилось, заказ передан логисту на ручную обработку.",
|
comment: "Автоматическое согласование не завершилось, заказ передан логисту на ручную обработку.",
|
||||||
ownerRole: "logistician",
|
ownerRole: "logistician",
|
||||||
|
|
@ -237,8 +219,8 @@ export const ORDER_STATUS_TRANSITIONS = {
|
||||||
"В производстве": ["Готов к отгрузке", "Требует уточнения", "Отменён"],
|
"В производстве": ["Готов к отгрузке", "Требует уточнения", "Отменён"],
|
||||||
"Готов к отгрузке": ["Ожидает согласования доставки", "Ожидает ответа клиента", "Проблема доставки", "Отменён"],
|
"Готов к отгрузке": ["Ожидает согласования доставки", "Ожидает ответа клиента", "Проблема доставки", "Отменён"],
|
||||||
"Ожидает ответа клиента": ["Доставка согласована", "Передан логисту", "Платное хранение", "Проблема доставки", "Отменён"],
|
"Ожидает ответа клиента": ["Доставка согласована", "Передан логисту", "Платное хранение", "Проблема доставки", "Отменён"],
|
||||||
"Ожидает согласования доставки": ["Доставка согласована", "Самовывоз", "Требуется адрес", "Проблема доставки", "Отменён"],
|
"Ожидает согласования доставки": ["Доставка согласована", "Проблема доставки", "Отменён"],
|
||||||
"Доставка согласована": ["Назначен водитель", "Ожидает согласования доставки", "Проблема доставки", "Самовывоз", "Требуется адрес"],
|
"Доставка согласована": ["Назначен водитель", "Ожидает согласования доставки", "Проблема доставки"],
|
||||||
"Передан логисту": ["Доставка согласована", "Платное хранение", "Проблема доставки", "Отменён"],
|
"Передан логисту": ["Доставка согласована", "Платное хранение", "Проблема доставки", "Отменён"],
|
||||||
"Назначен водитель": ["Загружен", "Проблема доставки"],
|
"Назначен водитель": ["Загружен", "Проблема доставки"],
|
||||||
Загружен: ["Доставлен", "Проблема доставки"],
|
Загружен: ["Доставлен", "Проблема доставки"],
|
||||||
|
|
@ -246,14 +228,12 @@ export const ORDER_STATUS_TRANSITIONS = {
|
||||||
Доставлен: ["Закрыт"],
|
Доставлен: ["Закрыт"],
|
||||||
"Проблема доставки": ["Ожидает согласования доставки", "Назначен водитель", "Отменён", "Закрыт"],
|
"Проблема доставки": ["Ожидает согласования доставки", "Назначен водитель", "Отменён", "Закрыт"],
|
||||||
"Платное хранение": ["Доставка согласована", "Отменён", "Закрыт"],
|
"Платное хранение": ["Доставка согласована", "Отменён", "Закрыт"],
|
||||||
"Самовывоз": ["Доставка согласована", "Закрыт", "Отменён", "Платное хранение"],
|
|
||||||
"Требуется адрес": ["Доставка согласована", "Самовывоз", "Отменён", "Проблема доставки"],
|
|
||||||
Закрыт: [],
|
Закрыт: [],
|
||||||
Отменён: [],
|
Отменён: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ROLE_TRANSITION_TARGETS = {
|
export const ROLE_TRANSITION_TARGETS = {
|
||||||
manager: [...ORDER_STATUSES],
|
manager: ORDER_STATUSES,
|
||||||
production_lead: ["В очереди производства", "В производстве", "Готов к отгрузке", "Требует уточнения", "Отменён"],
|
production_lead: ["В очереди производства", "В производстве", "Готов к отгрузке", "Требует уточнения", "Отменён"],
|
||||||
logistician: [
|
logistician: [
|
||||||
"Новый",
|
"Новый",
|
||||||
|
|
@ -263,8 +243,6 @@ export const ROLE_TRANSITION_TARGETS = {
|
||||||
"Доставка согласована",
|
"Доставка согласована",
|
||||||
"Передан логисту",
|
"Передан логисту",
|
||||||
"Назначен водитель",
|
"Назначен водитель",
|
||||||
"Самовывоз",
|
|
||||||
"Требуется адрес",
|
|
||||||
"Проблема доставки",
|
"Проблема доставки",
|
||||||
"Платное хранение",
|
"Платное хранение",
|
||||||
"Закрыт",
|
"Закрыт",
|
||||||
|
|
@ -286,8 +264,6 @@ export const LOGISTICS_STATUSES = [
|
||||||
"Ожидает согласования доставки",
|
"Ожидает согласования доставки",
|
||||||
"Доставка согласована",
|
"Доставка согласована",
|
||||||
"Назначен водитель",
|
"Назначен водитель",
|
||||||
"Самовывоз",
|
|
||||||
"Требуется адрес",
|
|
||||||
"Проблема доставки",
|
"Проблема доставки",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -126,9 +126,9 @@ const isSignedOut = () => sessionStorage.getItem(SIGNED_OUT_FLAG) === "1";
|
||||||
|
|
||||||
/** Clear ALL auth state from storage — called on explicit signOut */
|
/** Clear ALL auth state from storage — called on explicit signOut */
|
||||||
const clearAllAuthStorage = () => {
|
const clearAllAuthStorage = () => {
|
||||||
// Clear Supabase secureStorage keys from localStorage
|
// Clear Supabase secureStorage keys from sessionStorage
|
||||||
localStorage.removeItem("supersam-auth");
|
sessionStorage.removeItem("supersam-auth");
|
||||||
localStorage.removeItem("supersam-ak");
|
sessionStorage.removeItem("supersam-ak");
|
||||||
// Clear local auth cache from localStorage
|
// Clear local auth cache from localStorage
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
localStorage.removeItem("construction-auth-role-hint");
|
localStorage.removeItem("construction-auth-role-hint");
|
||||||
|
|
@ -148,8 +148,6 @@ export const AuthProvider = ({ children }) => {
|
||||||
const [isOtpSent, setIsOtpSent] = useState(false);
|
const [isOtpSent, setIsOtpSent] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [authError, setAuthError] = useState("");
|
const [authError, setAuthError] = useState("");
|
||||||
// Track whether the initial session restore from Supabase has completed
|
|
||||||
const [isSessionLoading, setIsSessionLoading] = useState(() => !!(hasSupabaseConfig && supabase));
|
|
||||||
|
|
||||||
// Ref to prevent getSession from restoring session after explicit signOut
|
// Ref to prevent getSession from restoring session after explicit signOut
|
||||||
const signedOutRef = useRef(false);
|
const signedOutRef = useRef(false);
|
||||||
|
|
@ -159,31 +157,18 @@ export const AuthProvider = ({ children }) => {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track whether getSession() has resolved — onAuthStateChange's INITIAL_SESSION
|
|
||||||
// can fire with null before storage has been read, causing premature redirect.
|
|
||||||
// Only onAuthStateChange should update user AFTER initial load is complete.
|
|
||||||
let getSessionResolved = false;
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: { subscription },
|
data: { subscription },
|
||||||
} = supabase.auth.onAuthStateChange((event, session) => {
|
} = supabase.auth.onAuthStateChange((_event, session) => {
|
||||||
// During initial load, ignore null sessions from onAuthStateChange —
|
|
||||||
// getSession() is the authoritative source. SIGNED_OUT events are always valid.
|
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
if (!getSessionResolved && event === "INITIAL_SESSION") {
|
|
||||||
// Don't set user=null or isSessionLoading=false yet — let getSession() decide.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setAuthError("");
|
setAuthError("");
|
||||||
window.__supersam_user_id__ = null;
|
window.__supersam_user_id__ = null;
|
||||||
setIsSessionLoading(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block session restore if user explicitly signed out (ref or sessionStorage flag)
|
// Block session restore if user explicitly signed out (ref or sessionStorage flag)
|
||||||
if (signedOutRef.current || isSignedOut()) {
|
if (signedOutRef.current || isSignedOut()) {
|
||||||
setIsSessionLoading(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,29 +182,24 @@ export const AuthProvider = ({ children }) => {
|
||||||
} else {
|
} else {
|
||||||
setUser({ ...baseUser, role: baseUser.role || "manager" });
|
setUser({ ...baseUser, role: baseUser.role || "manager" });
|
||||||
}
|
}
|
||||||
setIsSessionLoading(false);
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setIsSessionLoading(false);
|
|
||||||
}
|
}
|
||||||
setAuthError("");
|
setAuthError("");
|
||||||
});
|
});
|
||||||
|
|
||||||
supabase.auth.getSession().then(({ data, error }) => {
|
supabase.auth.getSession().then(({ data, error }) => {
|
||||||
getSessionResolved = true;
|
|
||||||
if (error && isStaleRefreshTokenError(error)) {
|
if (error && isStaleRefreshTokenError(error)) {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setAuthError("Сессия истекла. Войдите заново.");
|
setAuthError("Сессия истекла. Войдите заново.");
|
||||||
clearAllAuthStorage();
|
clearAllAuthStorage();
|
||||||
void supabase.auth.signOut({ scope: "local" });
|
void supabase.auth.signOut({ scope: "local" });
|
||||||
setIsSessionLoading(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block session restore if user explicitly signed out (ref or sessionStorage flag)
|
// Block session restore if user explicitly signed out (ref or sessionStorage flag)
|
||||||
if (signedOutRef.current || isSignedOut()) {
|
if (signedOutRef.current || isSignedOut()) {
|
||||||
setIsSessionLoading(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -232,17 +212,9 @@ export const AuthProvider = ({ children }) => {
|
||||||
} else {
|
} else {
|
||||||
setUser({ ...baseUser, role: baseUser.role || "manager" });
|
setUser({ ...baseUser, role: baseUser.role || "manager" });
|
||||||
}
|
}
|
||||||
setIsSessionLoading(false);
|
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
setIsSessionLoading(false);
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
setIsSessionLoading(false);
|
|
||||||
}
|
}
|
||||||
}).catch(() => {
|
|
||||||
// getSession rejected — ensure we don't hang forever
|
|
||||||
setIsSessionLoading(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => subscription.unsubscribe();
|
return () => subscription.unsubscribe();
|
||||||
|
|
@ -394,7 +366,6 @@ export const AuthProvider = ({ children }) => {
|
||||||
pendingEmail,
|
pendingEmail,
|
||||||
isOtpSent,
|
isOtpSent,
|
||||||
isLoading,
|
isLoading,
|
||||||
isSessionLoading,
|
|
||||||
authError,
|
authError,
|
||||||
isDemoMode,
|
isDemoMode,
|
||||||
requestOtp,
|
requestOtp,
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,22 @@ import {
|
||||||
ORDER_GROUP_DISPLAY_STATUS_OPTIONS,
|
ORDER_GROUP_DISPLAY_STATUS_OPTIONS,
|
||||||
getOrderGroupDisplayStatusValue,
|
getOrderGroupDisplayStatusValue,
|
||||||
} from "../services/orderGroupViews";
|
} from "../services/orderGroupViews";
|
||||||
import { getErrorMessage } from "../utils/deliveryUtils";
|
|
||||||
|
const getErrorMessage = (error, fallbackMessage) => {
|
||||||
|
if (!error) {
|
||||||
|
return fallbackMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message || fallbackMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof error === "string") {
|
||||||
|
return error || fallbackMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
return error?.message || fallbackMessage;
|
||||||
|
};
|
||||||
|
|
||||||
export const useOrderGroups = () => {
|
export const useOrderGroups = () => {
|
||||||
const [orderGroups, setOrderGroups] = React.useState(() => []);
|
const [orderGroups, setOrderGroups] = React.useState(() => []);
|
||||||
|
|
@ -19,8 +34,6 @@ export const useOrderGroups = () => {
|
||||||
const [isLoading, setIsLoading] = React.useState(true);
|
const [isLoading, setIsLoading] = React.useState(true);
|
||||||
const [loadError, setLoadError] = React.useState("");
|
const [loadError, setLoadError] = React.useState("");
|
||||||
const [isSavingDeliveryChoice, setIsSavingDeliveryChoice] = React.useState(false);
|
const [isSavingDeliveryChoice, setIsSavingDeliveryChoice] = React.useState(false);
|
||||||
const [isSavingDriverAssignment, setIsSavingDriverAssignment] = React.useState(false);
|
|
||||||
const [isSavingStatusChange, setIsSavingStatusChange] = React.useState(false);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
@ -102,10 +115,6 @@ export const useOrderGroups = () => {
|
||||||
orderGroupId,
|
orderGroupId,
|
||||||
deliveryDate,
|
deliveryDate,
|
||||||
deliveryTime,
|
deliveryTime,
|
||||||
deliveryType,
|
|
||||||
deliveryAddress,
|
|
||||||
pickupDate,
|
|
||||||
pickupTimeSlot,
|
|
||||||
}) => {
|
}) => {
|
||||||
setIsSavingDeliveryChoice(true);
|
setIsSavingDeliveryChoice(true);
|
||||||
|
|
||||||
|
|
@ -116,10 +125,6 @@ export const useOrderGroups = () => {
|
||||||
orderGroupId,
|
orderGroupId,
|
||||||
deliveryDate,
|
deliveryDate,
|
||||||
deliveryTime,
|
deliveryTime,
|
||||||
deliveryType,
|
|
||||||
deliveryAddress,
|
|
||||||
pickupDate,
|
|
||||||
pickupTimeSlot,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
|
|
@ -144,7 +149,7 @@ export const useOrderGroups = () => {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const assignDriver = React.useCallback(async ({ orderGroupId, driverId }) => {
|
const assignDriver = React.useCallback(async ({ orderGroupId, driverId }) => {
|
||||||
setIsSavingDriverAssignment(true);
|
setIsSavingDeliveryChoice(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await assignDriverToOrderGroup({ orderGroupId, driverId });
|
const result = await assignDriverToOrderGroup({ orderGroupId, driverId });
|
||||||
|
|
@ -167,12 +172,12 @@ export const useOrderGroups = () => {
|
||||||
error: getErrorMessage(error, "Не удалось назначить водителя"),
|
error: getErrorMessage(error, "Не удалось назначить водителя"),
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
setIsSavingDriverAssignment(false);
|
setIsSavingDeliveryChoice(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const changeDeliveryStatus = React.useCallback(async ({ orderGroupId, status, details }) => {
|
const changeDeliveryStatus = React.useCallback(async ({ orderGroupId, status, details }) => {
|
||||||
setIsSavingStatusChange(true);
|
setIsSavingDeliveryChoice(true);
|
||||||
try {
|
try {
|
||||||
const result = await updateDeliveryStatus({ orderGroupId, status, details });
|
const result = await updateDeliveryStatus({ orderGroupId, status, details });
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
|
|
@ -191,7 +196,7 @@ export const useOrderGroups = () => {
|
||||||
error: getErrorMessage(error, "Не удалось обновить статус"),
|
error: getErrorMessage(error, "Не удалось обновить статус"),
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
setIsSavingStatusChange(false);
|
setIsSavingDeliveryChoice(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -212,8 +217,6 @@ export const useOrderGroups = () => {
|
||||||
assignDriver,
|
assignDriver,
|
||||||
changeDeliveryStatus,
|
changeDeliveryStatus,
|
||||||
isSavingDeliveryChoice,
|
isSavingDeliveryChoice,
|
||||||
isSavingDriverAssignment,
|
|
||||||
isSavingStatusChange,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
loadError,
|
loadError,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import { useState, useEffect, useCallback } from "react";
|
|
||||||
import { supabase, hasSupabaseConfig } from "../supabaseClient";
|
|
||||||
|
|
||||||
const PERIOD_DAYS = { today: 1, "7d": 7, "30d": 30, all: 0 };
|
|
||||||
|
|
||||||
export const usePickupStats = (period = "7d") => {
|
|
||||||
const [stats, setStats] = useState(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
|
|
||||||
const days = PERIOD_DAYS[period] ?? 7;
|
|
||||||
|
|
||||||
const fetchStats = useCallback(async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
if (!hasSupabaseConfig || !supabase) throw new Error("Supabase не сконфигурирован");
|
|
||||||
const { data, error: rpcError } = await supabase.rpc("admin_pickup_stats", { p_days: days, p_date_from: null, p_date_to: null });
|
|
||||||
if (rpcError) throw rpcError;
|
|
||||||
setStats(data?.[0] || null);
|
|
||||||
} catch (e) {
|
|
||||||
setError(e.message || "Ошибка загрузки статистики самовывоза");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [days]);
|
|
||||||
|
|
||||||
useEffect(() => { fetchStats(); }, [fetchStats]);
|
|
||||||
|
|
||||||
return { stats, isLoading, error, refetch: fetchStats };
|
|
||||||
};
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import { supabase } from "../supabaseClient";
|
|
||||||
|
|
||||||
const matchesStopWord = (name, stopWords) => {
|
|
||||||
if (!stopWords || !stopWords.length) return false;
|
|
||||||
const lower = name.toLowerCase();
|
|
||||||
return stopWords.some((sw) => lower.includes(sw.toLowerCase()));
|
|
||||||
};
|
|
||||||
|
|
||||||
const useStopWords = () => {
|
|
||||||
const [stopWords, setStopWords] = React.useState([]);
|
|
||||||
const [active, setActive] = React.useState(true);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!supabase) return;
|
|
||||||
Promise.all([
|
|
||||||
supabase.from("stop_words").select("word").then((r) => r.data || []),
|
|
||||||
supabase
|
|
||||||
.from("stop_words_scope")
|
|
||||||
.select("scope")
|
|
||||||
.eq("id", 1)
|
|
||||||
.single()
|
|
||||||
.then((r) => r.data),
|
|
||||||
]).then(([words, scopeRow]) => {
|
|
||||||
setStopWords(words.map((d) => d.word));
|
|
||||||
setActive(scopeRow?.scope !== "client_only");
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { stopWords, active };
|
|
||||||
};
|
|
||||||
|
|
||||||
export { matchesStopWord, useStopWords };
|
|
||||||
|
|
@ -102,7 +102,7 @@ export const AppShell = ({
|
||||||
{user.name} · {ROLE_LABELS[user.role] || user.role}
|
{user.name} · {ROLE_LABELS[user.role] || user.role}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 md:flex-shrink-0">
|
<div className="flex flex-wrap items-center justify-end gap-2 md:flex-shrink-0">
|
||||||
<NotificationBell
|
<NotificationBell
|
||||||
notifications={notifications}
|
notifications={notifications}
|
||||||
unreadCount={unreadCount}
|
unreadCount={unreadCount}
|
||||||
|
|
@ -112,7 +112,7 @@ export const AppShell = ({
|
||||||
/>
|
/>
|
||||||
{onOpenGuide ? (
|
{onOpenGuide ? (
|
||||||
<Button size="sm" variant="ghost" onClick={onOpenGuide} aria-label="Справка">
|
<Button size="sm" variant="ghost" onClick={onOpenGuide} aria-label="Справка">
|
||||||
?
|
{isGuideOpen ? "Назад" : "?"}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<PwaInstallButton onInstall={onInstallApp} isInstalled={isInstalled} isInstallAvailable={isInstallAvailable} />
|
<PwaInstallButton onInstall={onInstallApp} isInstalled={isInstalled} isInstallAvailable={isInstallAvailable} />
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import React from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { DeliveryChoiceFlow } from "../components/client/DeliveryChoiceFlow";
|
import { DeliveryChoiceFlow } from "../components/client/DeliveryChoiceFlow";
|
||||||
import { DeliverySlotsPicker } from "../components/client/DeliverySlotsPicker";
|
import { DeliverySlotsPicker } from "../components/client/DeliverySlotsPicker";
|
||||||
import { PickupSlotsPicker } from "../components/client/PickupSlotsPicker";
|
|
||||||
import { OrderCompositionPanel } from "../components/client/OrderCompositionPanel";
|
import { OrderCompositionPanel } from "../components/client/OrderCompositionPanel";
|
||||||
import { getInvitationReferenceLabel } from "../components/client/invitationReference";
|
import { getInvitationReferenceLabel } from "../components/client/invitationReference";
|
||||||
import { DeliveryStateNotice } from "../components/client/DeliveryStateNotice";
|
import { DeliveryStateNotice } from "../components/client/DeliveryStateNotice";
|
||||||
|
|
@ -132,26 +131,10 @@ export const buildDeliveryConfirmationPayload = ({
|
||||||
slot,
|
slot,
|
||||||
invitation,
|
invitation,
|
||||||
searchDate,
|
searchDate,
|
||||||
deliveryType = "delivery",
|
}) => ({
|
||||||
pickupDate,
|
deliveryDate: slot?.date || searchDate || invitation?.deliveryDate || undefined,
|
||||||
pickupTimeSlot,
|
deliveryTime: slot?.time || invitation?.deliveryTime || undefined,
|
||||||
}) => {
|
});
|
||||||
if (deliveryType === "pickup") {
|
|
||||||
return {
|
|
||||||
deliveryType: "pickup",
|
|
||||||
pickupDate: pickupDate || slot?.date || undefined,
|
|
||||||
pickupTimeSlot: pickupTimeSlot || slot?.time || undefined,
|
|
||||||
deliveryDate: pickupDate || slot?.date || searchDate || invitation?.deliveryDate || undefined,
|
|
||||||
deliveryTime: pickupTimeSlot || slot?.time || undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
deliveryType: "delivery",
|
|
||||||
deliveryDate: slot?.date || searchDate || invitation?.deliveryDate || undefined,
|
|
||||||
deliveryTime: slot?.time || invitation?.deliveryTime || undefined,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const buildSelectedSlotFromInvitation = (invitation, slots = []) => {
|
export const buildSelectedSlotFromInvitation = (invitation, slots = []) => {
|
||||||
if (!invitation?.deliveryDate) {
|
if (!invitation?.deliveryDate) {
|
||||||
|
|
@ -181,9 +164,6 @@ export const getClientDeliveryHeroDescription = (isActiveState, isChoiceSaved) =
|
||||||
: "По этому заказу согласование доставки завершено или передано логисту.";
|
: "По этому заказу согласование доставки завершено или передано логисту.";
|
||||||
};
|
};
|
||||||
|
|
||||||
const TAB_DELIVERY = "delivery";
|
|
||||||
const TAB_PICKUP = "pickup";
|
|
||||||
|
|
||||||
export const ClientDeliveryPage = () => {
|
export const ClientDeliveryPage = () => {
|
||||||
const { token } = useParams();
|
const { token } = useParams();
|
||||||
const [invitation, setInvitation] = React.useState(null);
|
const [invitation, setInvitation] = React.useState(null);
|
||||||
|
|
@ -193,8 +173,6 @@ export const ClientDeliveryPage = () => {
|
||||||
const [selectedSlotId, setSelectedSlotId] = React.useState(null);
|
const [selectedSlotId, setSelectedSlotId] = React.useState(null);
|
||||||
const [selectedSlot, setSelectedSlot] = React.useState(null);
|
const [selectedSlot, setSelectedSlot] = React.useState(null);
|
||||||
const [choiceSaved, setChoiceSaved] = React.useState(false);
|
const [choiceSaved, setChoiceSaved] = React.useState(false);
|
||||||
const [activeTab, setActiveTab] = React.useState(TAB_DELIVERY);
|
|
||||||
const [deliveryAddress, setDeliveryAddress] = React.useState("");
|
|
||||||
const referenceDate = React.useMemo(() => new Date(), [token]);
|
const referenceDate = React.useMemo(() => new Date(), [token]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
@ -218,10 +196,6 @@ export const ClientDeliveryPage = () => {
|
||||||
const loadedInvitation = await fetchDeliveryInvitation(token);
|
const loadedInvitation = await fetchDeliveryInvitation(token);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setInvitation(loadedInvitation);
|
setInvitation(loadedInvitation);
|
||||||
// If invitation already has deliveryType=pickup, pre-select pickup tab
|
|
||||||
if (loadedInvitation?.deliveryType === "pickup") {
|
|
||||||
setActiveTab(TAB_PICKUP);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (fetchError) {
|
} catch (fetchError) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
|
|
@ -275,14 +249,6 @@ export const ClientDeliveryPage = () => {
|
||||||
token,
|
token,
|
||||||
deliveryTime: effectiveSelectedSlot.time,
|
deliveryTime: effectiveSelectedSlot.time,
|
||||||
deliveryDate: effectiveSelectedSlot.date,
|
deliveryDate: effectiveSelectedSlot.date,
|
||||||
deliveryType: activeTab,
|
|
||||||
...(activeTab === TAB_PICKUP ? {
|
|
||||||
pickupDate: effectiveSelectedSlot.date,
|
|
||||||
pickupTimeSlot: effectiveSelectedSlot.time,
|
|
||||||
} : {}),
|
|
||||||
...(activeTab === TAB_DELIVERY && deliveryAddress.trim() ? {
|
|
||||||
deliveryAddress: deliveryAddress.trim(),
|
|
||||||
} : {}),
|
|
||||||
});
|
});
|
||||||
const loadedInvitation = await fetchDeliveryInvitation(token);
|
const loadedInvitation = await fetchDeliveryInvitation(token);
|
||||||
setInvitation(loadedInvitation);
|
setInvitation(loadedInvitation);
|
||||||
|
|
@ -369,99 +335,22 @@ export const ClientDeliveryPage = () => {
|
||||||
{isChoiceSaved && savedChoiceLabel ? (
|
{isChoiceSaved && savedChoiceLabel ? (
|
||||||
<Panel className="space-y-2 p-5 sm:p-6">
|
<Panel className="space-y-2 p-5 sm:p-6">
|
||||||
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Ваш выбор</p>
|
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Ваш выбор</p>
|
||||||
<h2 className="text-xl font-semibold leading-tight">
|
<h2 className="text-xl font-semibold leading-tight">Сохранено: {savedChoiceLabel}</h2>
|
||||||
{invitation?.deliveryType === "pickup" ? "Самовывоз" : "Доставка"}: {savedChoiceLabel}
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
{getInvitationReferenceLabel(invitation)}
|
{getInvitationReferenceLabel(invitation)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
Статус: {invitation?.deliveryType === "pickup" ? "самовывоз" : "доставка"} уже согласован. При повторном открытии этой ссылки будет показан тот же выбор.
|
Статус: доставка уже согласована. При повторном открытии этой ссылки будет показан тот же выбор.
|
||||||
</p>
|
</p>
|
||||||
</Panel>
|
</Panel>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isActiveState && !isChoiceSaved ? (
|
{isActiveState && !isChoiceSaved && slots.length ? (
|
||||||
<>
|
<DeliverySlotsPicker
|
||||||
{/* Tab switcher */}
|
slots={slots}
|
||||||
<div className="flex gap-2 rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] p-1">
|
onSelectSlot={handleSlotSelect}
|
||||||
<button
|
selectedSlotId={selectedSlotId}
|
||||||
type="button"
|
/>
|
||||||
className={`flex-1 rounded-[24px] px-4 py-2.5 text-sm font-semibold transition ${
|
|
||||||
activeTab === TAB_DELIVERY
|
|
||||||
? "bg-[var(--color-accent)] text-white"
|
|
||||||
: "text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
|
||||||
}`}
|
|
||||||
onClick={() => {
|
|
||||||
setActiveTab(TAB_DELIVERY);
|
|
||||||
setSelectedSlotId(null);
|
|
||||||
setSelectedSlot(null);
|
|
||||||
setActionMessage("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
🚚 Доставка
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`flex-1 rounded-[24px] px-4 py-2.5 text-sm font-semibold transition ${
|
|
||||||
activeTab === TAB_PICKUP
|
|
||||||
? "bg-[var(--color-accent)] text-white"
|
|
||||||
: "text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
|
||||||
}`}
|
|
||||||
onClick={() => {
|
|
||||||
setActiveTab(TAB_PICKUP);
|
|
||||||
setSelectedSlotId(null);
|
|
||||||
setSelectedSlot(null);
|
|
||||||
setActionMessage("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
🏪 Самовывоз
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeTab === TAB_DELIVERY && !invitation?.deliveryAddress && !invitation?.customerAddress && (
|
|
||||||
<Panel className="space-y-3 border-[rgba(239,68,68,0.3)] bg-[var(--color-surface)] p-5 sm:p-6">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<span className="text-xl">📍</span>
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.16em] text-[var(--color-text)]">Укажите адрес доставки</p>
|
|
||||||
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
|
||||||
Адрес доставки отсутствует в заказе. Пожалуйста, введите полный адрес, куда нужно привезти заказ.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={deliveryAddress}
|
|
||||||
onChange={(e) => setDeliveryAddress(e.target.value)}
|
|
||||||
placeholder="Город, улица, дом, квартира"
|
|
||||||
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] px-4 py-3 text-sm text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)] focus:outline-none"
|
|
||||||
/>
|
|
||||||
</Panel>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === TAB_DELIVERY && slots.length ? (
|
|
||||||
<DeliverySlotsPicker
|
|
||||||
slots={slots}
|
|
||||||
onSelectSlot={handleSlotSelect}
|
|
||||||
selectedSlotId={selectedSlotId}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{activeTab === TAB_PICKUP ? (
|
|
||||||
<PickupSlotsPicker
|
|
||||||
onSelectSlot={handleSlotSelect}
|
|
||||||
selectedSlotId={selectedSlotId}
|
|
||||||
referenceDate={referenceDate}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{activeTab === TAB_DELIVERY && !slots.length ? (
|
|
||||||
<Panel className="p-5 sm:p-6">
|
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">Нет доступных слотов для выбора доставки.</p>
|
|
||||||
</Panel>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isActiveState && !isChoiceSaved ? (
|
{isActiveState && !isChoiceSaved ? (
|
||||||
|
|
@ -469,7 +358,6 @@ export const ClientDeliveryPage = () => {
|
||||||
invitation={invitation}
|
invitation={invitation}
|
||||||
selectedSlot={effectiveSelectedSlot}
|
selectedSlot={effectiveSelectedSlot}
|
||||||
onConfirmChoice={handleSaveChoice}
|
onConfirmChoice={handleSaveChoice}
|
||||||
deliveryType={activeTab}
|
|
||||||
/>
|
/>
|
||||||
) : !isActiveState && !isChoiceSaved ? (
|
) : !isActiveState && !isChoiceSaved ? (
|
||||||
<DeliveryStateNotice state={invitationState} />
|
<DeliveryStateNotice state={invitationState} />
|
||||||
|
|
@ -493,4 +381,4 @@ export const ClientDeliveryPage = () => {
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* Manages navigation, notifications, PWA status, and order-group state.
|
* Manages navigation, notifications, PWA status, and order-group state.
|
||||||
*/
|
*/
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Navigate, useNavigate, useSearchParams, useLocation } from "react-router-dom";
|
import { Navigate, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { DriverDeliveryPlanner } from "../components/driver/DriverDeliveryPlanner";
|
import { DriverDeliveryPlanner } from "../components/driver/DriverDeliveryPlanner";
|
||||||
import { LogisticsReadinessBoard } from "../components/logistics/LogisticsReadinessBoard";
|
import { LogisticsReadinessBoard } from "../components/logistics/LogisticsReadinessBoard";
|
||||||
import { OrdersTable } from "../components/orders/OrdersTable";
|
import { OrdersTable } from "../components/orders/OrdersTable";
|
||||||
|
|
@ -14,7 +14,6 @@ import UserManagementPanel from "../components/admin/UserManagementPanel";
|
||||||
import ErrorLogPanel from "../components/admin/ErrorLogPanel";
|
import ErrorLogPanel from "../components/admin/ErrorLogPanel";
|
||||||
import { StopWordsPanel } from "../components/admin/StopWordsPanel";
|
import { StopWordsPanel } from "../components/admin/StopWordsPanel";
|
||||||
import { ActionLogPanel } from "../components/admin/ActionLogPanel";
|
import { ActionLogPanel } from "../components/admin/ActionLogPanel";
|
||||||
import { SuggestionsPanel } from "../components/admin/SuggestionsPanel";
|
|
||||||
import { Panel } from "../components/UI/Panel";
|
import { Panel } from "../components/UI/Panel";
|
||||||
import { SkeletonPage, SkeletonTable } from "../components/UI/Loading";
|
import { SkeletonPage, SkeletonTable } from "../components/UI/Loading";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
|
@ -32,7 +31,6 @@ const MEGA_ADMIN_NAV = [
|
||||||
{ key: "errors", label: "Ошибки", description: "Журнал ошибок приложения.", badge: null },
|
{ key: "errors", label: "Ошибки", description: "Журнал ошибок приложения.", badge: null },
|
||||||
{ key: "stop_words", label: "Стоп-слова", description: "Слова, исключаемые из клиентской карточки.", badge: null },
|
{ key: "stop_words", label: "Стоп-слова", description: "Слова, исключаемые из клиентской карточки.", badge: null },
|
||||||
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
|
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
|
||||||
{ key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Role → Default Section Map ─────────────────────────────────────────────
|
// ── Role → Default Section Map ─────────────────────────────────────────────
|
||||||
|
|
@ -46,8 +44,8 @@ const ROLE_SECTION = {
|
||||||
|
|
||||||
// ── Dashboard Component ────────────────────────────────────────────────────
|
// ── Dashboard Component ────────────────────────────────────────────────────
|
||||||
export const DashboardPage = () => {
|
export const DashboardPage = () => {
|
||||||
const { user, signOut, isSessionLoading } = useAuth();
|
// ── Auth & Navigation ────────────────────────────────────────────────────
|
||||||
const location = useLocation();
|
const { user, signOut } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const userRole = user?.role;
|
const userRole = user?.role;
|
||||||
|
|
@ -123,7 +121,6 @@ export const DashboardPage = () => {
|
||||||
{ key: "stop_words", label: "Стоп-слова", description: "Слова, исключаемые из карточки.", badge: null },
|
{ key: "stop_words", label: "Стоп-слова", description: "Слова, исключаемые из карточки.", badge: null },
|
||||||
{ key: "errors", label: "Ошибки", description: "Журнал ошибок приложения.", badge: null },
|
{ key: "errors", label: "Ошибки", description: "Журнал ошибок приложения.", badge: null },
|
||||||
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
|
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
|
||||||
{ key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null },
|
|
||||||
]
|
]
|
||||||
: userRole === "logistician"
|
: userRole === "logistician"
|
||||||
? [
|
? [
|
||||||
|
|
@ -131,7 +128,6 @@ export const DashboardPage = () => {
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
{ key: section.key, label: section.label, description: section.description, badge: String(allOrderGroups.length || orderGroups.length || 0) },
|
{ key: section.key, label: section.label, description: section.description, badge: String(allOrderGroups.length || orderGroups.length || 0) },
|
||||||
{ key: "suggestions", label: "Предложения", description: "Предложить улучшение.", badge: null },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const activeSectionMeta = navItems.find((n) => n.key === activeSection) || navItems[0];
|
const activeSectionMeta = navItems.find((n) => n.key === activeSection) || navItems[0];
|
||||||
|
|
@ -139,19 +135,8 @@ export const DashboardPage = () => {
|
||||||
// ── Auth Guard ────────────────────────────────────────────────────────────
|
// ── Auth Guard ────────────────────────────────────────────────────────────
|
||||||
const isGuideOpen = false;
|
const isGuideOpen = false;
|
||||||
|
|
||||||
const ALLOWED_DASHBOARD_ROLES = ["admin", "mega_admin", "manager", "logistician", "driver"];
|
|
||||||
|
|
||||||
// Wait for session restore before deciding redirect
|
|
||||||
if (isSessionLoading) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return <Navigate to={`/login?redirect=${encodeURIComponent(location.pathname + location.search)}`} replace />;
|
return <Navigate to="/login" replace />;
|
||||||
}
|
|
||||||
|
|
||||||
if (!ALLOWED_DASHBOARD_ROLES.includes(userRole)) {
|
|
||||||
return <Navigate to="/forbidden" replace />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Section Renderer ──────────────────────────────────────────────────────
|
// ── Section Renderer ──────────────────────────────────────────────────────
|
||||||
|
|
@ -162,7 +147,6 @@ const ALLOWED_DASHBOARD_ROLES = ["admin", "mega_admin", "manager", "logistician"
|
||||||
if (activeSection === "stop_words") return <div className="space-y-6 xl:space-y-8"><StopWordsPanel /></div>;
|
if (activeSection === "stop_words") return <div className="space-y-6 xl:space-y-8"><StopWordsPanel /></div>;
|
||||||
if (activeSection === "errors") return <div className="space-y-6 xl:space-y-8"><ErrorLogPanel /></div>;
|
if (activeSection === "errors") return <div className="space-y-6 xl:space-y-8"><ErrorLogPanel /></div>;
|
||||||
if (activeSection === "action_log") return <div className="space-y-6 xl:space-y-8"><ActionLogPanel /></div>;
|
if (activeSection === "action_log") return <div className="space-y-6 xl:space-y-8"><ActionLogPanel /></div>;
|
||||||
if (activeSection === "suggestions") return <div className="space-y-6 xl:space-y-8"><SuggestionsPanel /></div>;
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
if (userRole === "driver") {
|
if (userRole === "driver") {
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { Button } from "../components/UI/Button";
|
|
||||||
import { Panel } from "../components/UI/Panel";
|
|
||||||
|
|
||||||
export const ForbiddenPage = () => {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center px-4">
|
|
||||||
<Panel className="max-w-lg p-8 text-center">
|
|
||||||
<h1 className="text-3xl font-semibold">Доступ ограничен</h1>
|
|
||||||
<p className="mt-3 text-sm text-[var(--color-text-muted)]">
|
|
||||||
У вас нет прав для просмотра этой страницы. Обратитесь к администратору или войдите с другой учётной записью.
|
|
||||||
</p>
|
|
||||||
<div className="mt-6 flex justify-center gap-3">
|
|
||||||
<Link to="/dashboard">
|
|
||||||
<Button variant="secondary">На главную</Button>
|
|
||||||
</Link>
|
|
||||||
<Link to="/login">
|
|
||||||
<Button>Войти</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -4,9 +4,8 @@
|
||||||
* params, loads drivers, and renders the OrderDetailPanel.
|
* params, loads drivers, and renders the OrderDetailPanel.
|
||||||
*/
|
*/
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Navigate, useNavigate, useParams, useLocation } from "react-router-dom";
|
import { useNavigate, useParams, useLocation } from "react-router-dom";
|
||||||
import { OrderDetailPanel } from "../components/orders/OrderDetailPanel";
|
import { OrderDetailPanel } from "../components/orders/OrderDetailPanel";
|
||||||
import ErrorBoundary from "../components/ErrorBoundary";
|
|
||||||
import { Button } from "../components/UI/Button";
|
import { Button } from "../components/UI/Button";
|
||||||
import { Panel } from "../components/UI/Panel";
|
import { Panel } from "../components/UI/Panel";
|
||||||
import { SkeletonPanel } from "../components/UI/Loading";
|
import { SkeletonPanel } from "../components/UI/Loading";
|
||||||
|
|
@ -14,25 +13,22 @@ import { useAuth } from "../context/AuthContext";
|
||||||
import { fetchDrivers } from "../services/supabase/userRepository";
|
import { fetchDrivers } from "../services/supabase/userRepository";
|
||||||
import { useOrderGroups } from "../hooks/useOrderGroups";
|
import { useOrderGroups } from "../hooks/useOrderGroups";
|
||||||
|
|
||||||
const ALLOWED_ROLES = ["admin", "mega_admin", "manager", "logistician", "driver"];
|
// ── Component ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const GroupDetailPage = () => {
|
export const GroupDetailPage = () => {
|
||||||
// ── Route Params & Auth ───────────────────────────────────────────────────
|
// ── Route Params & Auth ───────────────────────────────────────────────────
|
||||||
const { groupId } = useParams();
|
const { groupId } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user, isSessionLoading } = useAuth();
|
const { user } = useAuth();
|
||||||
const userRole = user?.role;
|
const userRole = user?.role;
|
||||||
|
|
||||||
// ALL hooks must be called before any early return (Rules of Hooks)
|
// ── Order Groups Hook ─────────────────────────────────────────────────────
|
||||||
const {
|
const {
|
||||||
allOrderGroups,
|
allOrderGroups,
|
||||||
selectedOrderGroupId,
|
selectedOrderGroupId,
|
||||||
setSelectedOrderGroupId,
|
setSelectedOrderGroupId,
|
||||||
saveManualDeliveryChoice,
|
saveManualDeliveryChoice,
|
||||||
isSavingDeliveryChoice,
|
isSavingDeliveryChoice,
|
||||||
isSavingDriverAssignment,
|
|
||||||
isSavingStatusChange,
|
|
||||||
assignDriver,
|
assignDriver,
|
||||||
changeDeliveryStatus,
|
changeDeliveryStatus,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
|
@ -60,12 +56,13 @@ export const GroupDetailPage = () => {
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ALL hooks must be called before any early return (Rules of Hooks)
|
// ── Order Lookup ──────────────────────────────────────────────────────────
|
||||||
const order = isLoading ? null : (allOrderGroups.find((g) => g.id === groupId) ||
|
const order = isLoading ? null : (allOrderGroups.find((g) => g.id === groupId) ||
|
||||||
allOrderGroups.find((g) => g.id === selectedOrderGroupId) ||
|
allOrderGroups.find((g) => g.id === selectedOrderGroupId) ||
|
||||||
null);
|
null);
|
||||||
|
|
||||||
// Preserve the tab the user came from when going back
|
// Preserve the tab the user came from when going back
|
||||||
|
// ── Navigation ──────────────────────────────────────────────────────────────
|
||||||
const handleGoBack = React.useCallback(() => {
|
const handleGoBack = React.useCallback(() => {
|
||||||
if (window.history.length > 1) {
|
if (window.history.length > 1) {
|
||||||
navigate(-1);
|
navigate(-1);
|
||||||
|
|
@ -74,21 +71,6 @@ export const GroupDetailPage = () => {
|
||||||
}
|
}
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
// Wait for session restore before deciding redirect
|
|
||||||
if (isSessionLoading) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auth guard: redirect to login if not authenticated
|
|
||||||
if (!user) {
|
|
||||||
return <Navigate to={`/login?redirect=${encodeURIComponent(location.pathname + location.search)}`} replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Role guard: only allowed roles can access group details
|
|
||||||
if (!ALLOWED_ROLES.includes(userRole)) {
|
|
||||||
return <Navigate to="/forbidden" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-3xl space-y-5">
|
<div className="mx-auto w-full max-w-3xl space-y-5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
@ -100,23 +82,19 @@ export const GroupDetailPage = () => {
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<SkeletonPanel lines={6} />
|
<SkeletonPanel lines={6} />
|
||||||
) : order ? (
|
) : order ? (
|
||||||
<ErrorBoundary compact>
|
<OrderDetailPanel
|
||||||
<OrderDetailPanel
|
|
||||||
order={order}
|
order={order}
|
||||||
canManageDelivery={["manager", "logistician", "admin", "mega_admin"].includes(userRole)}
|
canManageDelivery={["manager", "logistician", "admin", "mega_admin"].includes(userRole)}
|
||||||
onSaveManualDeliveryChoice={saveManualDeliveryChoice}
|
onSaveManualDeliveryChoice={saveManualDeliveryChoice}
|
||||||
isSavingDeliveryChoice={isSavingDeliveryChoice}
|
isSavingDeliveryChoice={isSavingDeliveryChoice}
|
||||||
isSavingDriverAssignment={isSavingDriverAssignment}
|
|
||||||
isSavingStatusChange={isSavingStatusChange}
|
|
||||||
drivers={drivers}
|
drivers={drivers}
|
||||||
onAssignDriver={assignDriver}
|
onAssignDriver={assignDriver}
|
||||||
onChangeDeliveryStatus={changeDeliveryStatus}
|
onChangeDeliveryStatus={changeDeliveryStatus}
|
||||||
userRole={userRole}
|
userRole={userRole}
|
||||||
/>
|
/>
|
||||||
</ErrorBoundary>
|
|
||||||
) : (
|
) : (
|
||||||
<Panel className="p-6 text-sm text-[var(--color-text-muted)]">
|
<Panel className="p-6 text-sm text-[var(--color-text-muted)]">
|
||||||
Группа не найдена.
|
Группа доставки не найдена.
|
||||||
</Panel>
|
</Panel>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Navigate, useSearchParams } from "react-router-dom";
|
import { Navigate } from "react-router-dom";
|
||||||
import { ROLE_LABELS } from "../constants/roles";
|
import { ROLE_LABELS } from "../constants/roles";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { demoUsers } from "../data/mockAppData";
|
import { demoUsers } from "../data/mockAppData";
|
||||||
|
|
@ -14,9 +14,6 @@ export const LoginPage = () => {
|
||||||
const [otp, setOtp] = React.useState("");
|
const [otp, setOtp] = React.useState("");
|
||||||
const [error, setError] = React.useState("");
|
const [error, setError] = React.useState("");
|
||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const redirectUrl = searchParams.get("redirect") || "/dashboard";
|
|
||||||
|
|
||||||
const displayError = error || authError;
|
const displayError = error || authError;
|
||||||
|
|
||||||
const handleRequestOtp = async () => {
|
const handleRequestOtp = async () => {
|
||||||
|
|
@ -63,7 +60,7 @@ export const LoginPage = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
return <Navigate to={redirectUrl} replace />;
|
return <Navigate to="/dashboard" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { DashboardPage } from "./pages/DashboardPage";
|
||||||
import { GroupDetailPage } from "./pages/GroupDetailPage";
|
import { GroupDetailPage } from "./pages/GroupDetailPage";
|
||||||
import { LoginPage } from "./pages/LoginPage";
|
import { LoginPage } from "./pages/LoginPage";
|
||||||
import { NotFoundPage } from "./pages/NotFoundPage";
|
import { NotFoundPage } from "./pages/NotFoundPage";
|
||||||
import { ForbiddenPage } from "./pages/ForbiddenPage";
|
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
|
|
@ -25,10 +24,6 @@ export const router = createBrowserRouter([
|
||||||
path: "delivery/:token",
|
path: "delivery/:token",
|
||||||
element: <ClientDeliveryPage />,
|
element: <ClientDeliveryPage />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "forbidden",
|
|
||||||
element: <ForbiddenPage />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "dashboard",
|
path: "dashboard",
|
||||||
element: <DashboardPage />,
|
element: <DashboardPage />,
|
||||||
|
|
|
||||||
|
|
@ -223,14 +223,11 @@ export const fetchDeliveryInvitation = async (token) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const confirmDeliveryChoice = async ({ token, deliveryDate, deliveryTime, deliveryType, pickupDate, pickupTimeSlot, deliveryAddress }) => {
|
export const confirmDeliveryChoice = async ({ token, deliveryDate, deliveryTime }) => {
|
||||||
if (isLocalClientInvitationToken(token)) {
|
if (isLocalClientInvitationToken(token)) {
|
||||||
const baseInvitation = getCachedInvitation(token) ?? buildFallbackInvitation(token);
|
const baseInvitation = getCachedInvitation(token) ?? buildFallbackInvitation(token);
|
||||||
const invitation = cacheInvitation({
|
const invitation = cacheInvitation({
|
||||||
...baseInvitation,
|
...baseInvitation,
|
||||||
deliveryType: deliveryType || "delivery",
|
|
||||||
...(deliveryType === "pickup" ? { pickupDate, pickupTimeSlot } : {}),
|
|
||||||
...(deliveryType === "delivery" && deliveryAddress ? { deliveryAddress, customerAddress: deliveryAddress } : {}),
|
|
||||||
deliveryDate,
|
deliveryDate,
|
||||||
deliveryTime,
|
deliveryTime,
|
||||||
state: "confirmed",
|
state: "confirmed",
|
||||||
|
|
@ -245,10 +242,6 @@ export const confirmDeliveryChoice = async ({ token, deliveryDate, deliveryTime,
|
||||||
p_token: token,
|
p_token: token,
|
||||||
p_delivery_date: deliveryDate,
|
p_delivery_date: deliveryDate,
|
||||||
p_delivery_time: deliveryTime,
|
p_delivery_time: deliveryTime,
|
||||||
p_delivery_type: deliveryType || "delivery",
|
|
||||||
p_pickup_date: pickupDate || null,
|
|
||||||
p_pickup_time_slot: pickupTimeSlot || null,
|
|
||||||
p_delivery_address: deliveryAddress || null,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ const getDeliveryDate = (group) => normalizeDate(group.deliveryDate || group.cus
|
||||||
export const DELIVERY_GROUP_STATUS_LABELS = {
|
export const DELIVERY_GROUP_STATUS_LABELS = {
|
||||||
pending_confirmation: "Ожидает согласования",
|
pending_confirmation: "Ожидает согласования",
|
||||||
manual_confirmation_required: "Взят в ручное управление",
|
manual_confirmation_required: "Взят в ручное управление",
|
||||||
requires_address: "Требуется адрес",
|
|
||||||
agreed: "Согласовано",
|
agreed: "Согласовано",
|
||||||
driver_assigned: "Назначен водитель",
|
driver_assigned: "Назначен водитель",
|
||||||
loaded: "Загружено",
|
loaded: "Загружено",
|
||||||
|
|
@ -13,7 +12,6 @@ export const DELIVERY_GROUP_STATUS_LABELS = {
|
||||||
delivered: "Доставлено",
|
delivered: "Доставлено",
|
||||||
problem: "Проблема",
|
problem: "Проблема",
|
||||||
paid_storage: "Платное хранение",
|
paid_storage: "Платное хранение",
|
||||||
pickup: "Самовывоз",
|
|
||||||
cancelled: "Отменено",
|
cancelled: "Отменено",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import {
|
||||||
getOrderGroupDeliveryStatusLabel,
|
getOrderGroupDeliveryStatusLabel,
|
||||||
getOrderGroupStatusLabel,
|
getOrderGroupStatusLabel,
|
||||||
} from "../orderGroupViews";
|
} from "../orderGroupViews";
|
||||||
import { normalizeNom } from "../../utils/deliveryUtils";
|
|
||||||
|
|
||||||
const requireSupabase = () => {
|
const requireSupabase = () => {
|
||||||
if (!hasSupabaseConfig || !supabase) {
|
if (!hasSupabaseConfig || !supabase) {
|
||||||
|
|
@ -62,25 +61,6 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
|
||||||
const customerPhone = normalizeText(row.customer_phone || row.legacy_customer_phone || parsedKey.phone);
|
const customerPhone = normalizeText(row.customer_phone || row.legacy_customer_phone || parsedKey.phone);
|
||||||
const customerDate = normalizeText(row.customer_date || row.legacy_customer_date || parsedKey.date);
|
const customerDate = normalizeText(row.customer_date || row.legacy_customer_date || parsedKey.date);
|
||||||
const orderNumbers = toStringArray(row.order_numbers);
|
const orderNumbers = toStringArray(row.order_numbers);
|
||||||
|
|
||||||
// Extract ALL bill numbers from source_orders (1C sends full orderList in every source_order)
|
|
||||||
const allBillNumbers = (() => {
|
|
||||||
const srcOrders = row.source_orders;
|
|
||||||
if (!Array.isArray(srcOrders) || !srcOrders.length) return orderNumbers;
|
|
||||||
const seen = new Set();
|
|
||||||
const result = [];
|
|
||||||
for (const src of srcOrders) {
|
|
||||||
if (src && Array.isArray(src.orderList)) {
|
|
||||||
for (const ol of src.orderList) {
|
|
||||||
if (ol && ol.nom) {
|
|
||||||
const n = normalizeNom(ol.nom);
|
|
||||||
if (n && !seen.has(n)) { seen.add(n); result.push(n); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result.length > 0 ? result : orderNumbers;
|
|
||||||
})();
|
|
||||||
const inferredOrderCount = orderNumbers.length;
|
const inferredOrderCount = orderNumbers.length;
|
||||||
const ordersCount = toNumber(row.orders_count ?? row.orders_total ?? row.legacy_orders_total, inferredOrderCount);
|
const ordersCount = toNumber(row.orders_count ?? row.orders_total ?? row.legacy_orders_total, inferredOrderCount);
|
||||||
const readyCount = toNumber(
|
const readyCount = toNumber(
|
||||||
|
|
@ -110,26 +90,6 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const deliveryAddress = normalizeText(row.delivery_address) || extractAddressFromSourceOrders(row.source_orders);
|
const deliveryAddress = normalizeText(row.delivery_address) || extractAddressFromSourceOrders(row.source_orders);
|
||||||
|
|
||||||
// Detect pickup from source_orders ship field (1C sends "САМОВЫВОЗ")
|
|
||||||
const isPickupFromSource = Array.isArray(row.source_orders) && row.source_orders.length > 0
|
|
||||||
&& normalizeText(row.source_orders[0].ship || "").toUpperCase() === "САМОВЫВОЗ";
|
|
||||||
// Also treat address equal to "САМОВЫВОЗ" as pickup indicator
|
|
||||||
const isPickupAddress = deliveryAddress.toUpperCase() === "САМОВЫВОЗ";
|
|
||||||
|
|
||||||
// Resolve effective delivery type: DB field takes precedence, but if it says "delivery"
|
|
||||||
// while source data clearly indicates pickup, treat as pickup
|
|
||||||
const effectiveDeliveryType = (row.delivery_type === "pickup" || deliveryStatus === "pickup" || isPickupFromSource || isPickupAddress)
|
|
||||||
? "pickup"
|
|
||||||
: (row.delivery_type || "delivery");
|
|
||||||
|
|
||||||
// Preserve original address for pre-filling delivery form (don't clear for pickup)
|
|
||||||
const originalDeliveryAddress = deliveryAddress;
|
|
||||||
// For display: show nothing for pickup placeholder addresses
|
|
||||||
const resolvedDeliveryAddress = (effectiveDeliveryType === "pickup" && (deliveryAddress.toUpperCase() === "САМОВЫВОЗ" || !deliveryAddress))
|
|
||||||
? ""
|
|
||||||
: deliveryAddress;
|
|
||||||
|
|
||||||
const customerAddress = normalizeText(row.customer_address) || "";
|
const customerAddress = normalizeText(row.customer_address) || "";
|
||||||
|
|
||||||
const extractCity = (addr) => {
|
const extractCity = (addr) => {
|
||||||
|
|
@ -171,8 +131,7 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
|
||||||
customerPhone,
|
customerPhone,
|
||||||
customerPhoneNormalized: parsedKey.phone || normalizePhone(customerPhone),
|
customerPhoneNormalized: parsedKey.phone || normalizePhone(customerPhone),
|
||||||
customerDate,
|
customerDate,
|
||||||
deliveryAddress: resolvedDeliveryAddress,
|
deliveryAddress,
|
||||||
originalDeliveryAddress,
|
|
||||||
customerAddress,
|
customerAddress,
|
||||||
city,
|
city,
|
||||||
assignedDriverId: row.assigned_driver_id || null,
|
assignedDriverId: row.assigned_driver_id || null,
|
||||||
|
|
@ -181,7 +140,6 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
|
||||||
readyCount,
|
readyCount,
|
||||||
notReadyCount,
|
notReadyCount,
|
||||||
orderNumbers,
|
orderNumbers,
|
||||||
allBillNumbers,
|
|
||||||
status: row.status || "draft",
|
status: row.status || "draft",
|
||||||
smsSentAt: row.sms_sent_at || null,
|
smsSentAt: row.sms_sent_at || null,
|
||||||
firstSmsSentAt: row.first_sms_sent_at || null,
|
firstSmsSentAt: row.first_sms_sent_at || null,
|
||||||
|
|
@ -210,23 +168,19 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
|
||||||
deliveryDate,
|
deliveryDate,
|
||||||
deliveryTime,
|
deliveryTime,
|
||||||
deliveryDateSource: row.delivery_date_source || null,
|
deliveryDateSource: row.delivery_date_source || null,
|
||||||
deliveryType: effectiveDeliveryType,
|
|
||||||
pickupDate: row.pickup_date || null,
|
|
||||||
pickupTimeSlot: row.pickup_time_slot || null,
|
|
||||||
driverShipmentData: row.driver_shipment_data || null,
|
|
||||||
deliveryHalfDay: getOrderGroupDeliveryHalfDay({
|
deliveryHalfDay: getOrderGroupDeliveryHalfDay({
|
||||||
deliveryHalfDay: rawDeliveryHalfDay,
|
deliveryHalfDay: rawDeliveryHalfDay,
|
||||||
deliveryTime: rawDeliveryTime,
|
deliveryTime: rawDeliveryTime,
|
||||||
deliveryWindow: row.delivery_window,
|
deliveryWindow: row.delivery_window,
|
||||||
sourceOrders: row.source_orders,
|
sourceOrders: row.source_orders,
|
||||||
}),
|
}),
|
||||||
orderNumberSummary: allBillNumbers.length ? allBillNumbers.join(", ") : "Номера не указаны",
|
orderNumberSummary: orderNumbers.length ? orderNumbers.join(", ") : "Номера не указаны",
|
||||||
searchText: [
|
searchText: [
|
||||||
row.group_key,
|
row.group_key,
|
||||||
customerName,
|
customerName,
|
||||||
customerPhone,
|
customerPhone,
|
||||||
customerDate,
|
customerDate,
|
||||||
resolvedDeliveryAddress,
|
deliveryAddress,
|
||||||
customerAddress,
|
customerAddress,
|
||||||
city,
|
city,
|
||||||
rawDeliveryHalfDay,
|
rawDeliveryHalfDay,
|
||||||
|
|
@ -235,7 +189,6 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
|
||||||
deliveryStatus,
|
deliveryStatus,
|
||||||
getOrderGroupDeliveryStatusLabel(deliveryStatus),
|
getOrderGroupDeliveryStatusLabel(deliveryStatus),
|
||||||
orderNumbers.join(" "),
|
orderNumbers.join(" "),
|
||||||
allBillNumbers.join(" "),
|
|
||||||
row.status,
|
row.status,
|
||||||
getOrderGroupStatusLabel(row.status),
|
getOrderGroupStatusLabel(row.status),
|
||||||
getOrderGroupDeliveryHalfDay({
|
getOrderGroupDeliveryHalfDay({
|
||||||
|
|
@ -254,42 +207,23 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const ORDER_GROUP_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, order_list, order_list_structured, delivery_invitation_id, delivery_link, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot`;
|
|
||||||
|
|
||||||
export const updateOrderGroupDeliveryChoice = async ({
|
export const updateOrderGroupDeliveryChoice = async ({
|
||||||
orderGroupId,
|
orderGroupId,
|
||||||
deliveryDate,
|
deliveryDate,
|
||||||
deliveryTime,
|
deliveryTime,
|
||||||
deliveryType,
|
|
||||||
deliveryAddress,
|
|
||||||
pickupDate,
|
|
||||||
pickupTimeSlot,
|
|
||||||
}) => {
|
}) => {
|
||||||
return safeSupabaseCall(async () => {
|
return safeSupabaseCall(async () => {
|
||||||
const client = requireSupabase();
|
const client = requireSupabase();
|
||||||
const effectiveDeliveryStatus = deliveryType === "pickup" ? "pickup" : "agreed";
|
|
||||||
const updatePayload = {
|
|
||||||
delivery_status: effectiveDeliveryStatus,
|
|
||||||
delivery_date: deliveryDate,
|
|
||||||
delivery_time: deliveryTime,
|
|
||||||
delivery_type: deliveryType || "delivery",
|
|
||||||
delivery_date_source: "manual",
|
|
||||||
notification_status: "confirmed",
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
if (deliveryType === "pickup") {
|
|
||||||
updatePayload.pickup_date = pickupDate || null;
|
|
||||||
updatePayload.pickup_time_slot = pickupTimeSlot || null;
|
|
||||||
} else {
|
|
||||||
updatePayload.pickup_date = null;
|
|
||||||
updatePayload.pickup_time_slot = null;
|
|
||||||
if (deliveryAddress !== undefined) {
|
|
||||||
updatePayload.delivery_address = deliveryAddress;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const updateResult = await client
|
const updateResult = await client
|
||||||
.from("order_groups")
|
.from("order_groups")
|
||||||
.update(updatePayload)
|
.update({
|
||||||
|
delivery_status: "agreed",
|
||||||
|
delivery_date: deliveryDate,
|
||||||
|
delivery_time: deliveryTime,
|
||||||
|
delivery_date_source: "manual",
|
||||||
|
notification_status: "confirmed",
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
.eq("id", orderGroupId);
|
.eq("id", orderGroupId);
|
||||||
|
|
||||||
if (updateResult.error) {
|
if (updateResult.error) {
|
||||||
|
|
@ -298,7 +232,7 @@ export const updateOrderGroupDeliveryChoice = async ({
|
||||||
|
|
||||||
const { data, error } = await client
|
const { data, error } = await client
|
||||||
.from("order_groups")
|
.from("order_groups")
|
||||||
.select(ORDER_GROUP_SELECT_FIELDS)
|
.select("id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, order_list, order_list_structured, delivery_invitation_id, delivery_link, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name)")
|
||||||
.eq("id", orderGroupId)
|
.eq("id", orderGroupId)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
|
|
@ -306,13 +240,12 @@ export const updateOrderGroupDeliveryChoice = async ({
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
await logAction({ orderGroupId, action: "date_assigned", newValue: (deliveryType === "pickup" ? "pickup: " : "manual: ") + deliveryDate + " " + (deliveryTime || ""), details: { delivery_date_source: "manual", delivery_type: deliveryType, pickup_date: pickupDate, pickup_time_slot: pickupTimeSlot } }).catch(() => {});
|
await logAction({ orderGroupId, action: "date_assigned", newValue: "manual: " + deliveryDate + " " + (deliveryTime || ""), details: { delivery_date_source: "manual" } }).catch(() => {});
|
||||||
|
|
||||||
return mapOrderGroupRowToDeliveryGroup(data);
|
return mapOrderGroupRowToDeliveryGroup(data);
|
||||||
}, "Ошибка сохранения согласования доставки");
|
}, "Ошибка сохранения согласования доставки");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export const assignDriverToOrderGroup = async ({
|
export const assignDriverToOrderGroup = async ({
|
||||||
orderGroupId,
|
orderGroupId,
|
||||||
driverId,
|
driverId,
|
||||||
|
|
@ -453,7 +386,7 @@ export const fetchOrderGroups = async () => {
|
||||||
const client = requireSupabase();
|
const client = requireSupabase();
|
||||||
const { data, error } = await client
|
const { data, error } = await client
|
||||||
.from("order_groups")
|
.from("order_groups")
|
||||||
.select(ORDER_GROUP_SELECT_FIELDS)
|
.select("id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, order_list, order_list_structured, delivery_invitation_id, delivery_link, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name)")
|
||||||
.order("updated_at", { ascending: false });
|
.order("updated_at", { ascending: false });
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|
@ -475,4 +408,4 @@ export const fetchOrderGroups = async () => {
|
||||||
return group;
|
return group;
|
||||||
}).filter(Boolean);
|
}).filter(Boolean);
|
||||||
}, "Ошибка загрузки групп доставки");
|
}, "Ошибка загрузки групп доставки");
|
||||||
};
|
};
|
||||||
|
|
@ -158,16 +158,15 @@ describe("updateOrderGroupDeliveryChoice", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(fromMock).toHaveBeenCalledWith("order_groups");
|
expect(fromMock).toHaveBeenCalledWith("order_groups");
|
||||||
expect(updateMock).toHaveBeenCalledWith(expect.objectContaining({
|
expect(updateMock).toHaveBeenCalledWith({
|
||||||
delivery_status: "agreed",
|
delivery_status: "agreed",
|
||||||
delivery_date: "2026-05-13",
|
delivery_date: "2026-05-13",
|
||||||
delivery_time: "Первая половина дня",
|
delivery_time: "Первая половина дня",
|
||||||
delivery_type: "delivery",
|
|
||||||
notification_status: "confirmed",
|
notification_status: "confirmed",
|
||||||
updated_at: expect.any(String),
|
updated_at: expect.any(String),
|
||||||
}));
|
});
|
||||||
expect(eqMock).toHaveBeenCalledWith("id", "group-id");
|
expect(eqMock).toHaveBeenCalledWith("id", "group-id");
|
||||||
expect(selectMock).toHaveBeenCalledWith(expect.stringContaining("delivery_type, pickup_date, pickup_time_slot"));
|
expect(selectMock).toHaveBeenCalledWith("id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, order_list, order_list_structured, delivery_invitation_id, delivery_link, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name)");
|
||||||
expect(singleMock).toHaveBeenCalledTimes(1);
|
expect(singleMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,31 +6,31 @@ export const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||||
export const hasSupabaseConfig = Boolean(supabaseUrl && supabaseAnonKey);
|
export const hasSupabaseConfig = Boolean(supabaseUrl && supabaseAnonKey);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Secure storage for Supabase auth tokens.
|
* Secure session storage for Supabase auth tokens.
|
||||||
*
|
|
||||||
* Uses localStorage so the session is available across tabs (critical for
|
|
||||||
* direct links like /dashboard/group/:id opening in a new tab).
|
|
||||||
*
|
*
|
||||||
* Security properties:
|
* Security properties:
|
||||||
* - Tokens are obfuscated with a per-browser random key stored in localStorage
|
* - Uses sessionStorage (dies on tab close, not shared across tabs)
|
||||||
* - No plaintext tokens in localStorage — reduces impact of XSS
|
* - Tokens are obfuscated with a per-session random key before storage
|
||||||
|
* - No plaintext tokens in sessionStorage — reduces impact of XSS
|
||||||
* - Auto-clears on detection of tampered/missing data
|
* - Auto-clears on detection of tampered/missing data
|
||||||
* - Session survives tab close (unlike sessionStorage) — required for cross-tab
|
|
||||||
*
|
*
|
||||||
* This is NOT as secure as httpOnly cookies (which require server-side SSR),
|
* This is NOT as secure as httpOnly cookies (which require server-side SSR),
|
||||||
* but is the standard approach for SPA auth with Supabase.
|
* but provides significantly better protection than plaintext localStorage:
|
||||||
|
* - Tokens don't persist across browser restarts
|
||||||
|
* - Tokens aren't shared across tabs (reduces cross-tab attacks)
|
||||||
|
* - Obfuscation adds friction for casual XSS token theft
|
||||||
*/
|
*/
|
||||||
const STORAGE_KEY = "supersam-auth";
|
const STORAGE_KEY = "supersam-auth";
|
||||||
const KEY_KEY = "supersam-ak";
|
const KEY_KEY = "supersam-ak";
|
||||||
|
|
||||||
function _getKey() {
|
function _getKey() {
|
||||||
let key = localStorage.getItem(KEY_KEY);
|
let key = sessionStorage.getItem(KEY_KEY);
|
||||||
if (!key) {
|
if (!key) {
|
||||||
key = crypto.getRandomValues(new Uint8Array(32)).reduce(
|
key = crypto.getRandomValues(new Uint8Array(32)).reduce(
|
||||||
(s, b) => s + b.toString(16).padStart(2, "0"),
|
(s, b) => s + b.toString(16).padStart(2, "0"),
|
||||||
""
|
""
|
||||||
);
|
);
|
||||||
localStorage.setItem(KEY_KEY, key);
|
sessionStorage.setItem(KEY_KEY, key);
|
||||||
}
|
}
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
@ -60,15 +60,15 @@ async function _deobfuscate(obfuscated) {
|
||||||
return new TextDecoder().decode(result);
|
return new TextDecoder().decode(result);
|
||||||
} catch {
|
} catch {
|
||||||
// Tampered data — clear everything
|
// Tampered data — clear everything
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
sessionStorage.removeItem(STORAGE_KEY);
|
||||||
localStorage.removeItem(KEY_KEY);
|
sessionStorage.removeItem(KEY_KEY);
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const secureStorage = {
|
const secureStorage = {
|
||||||
getItem: async (key) => {
|
getItem: async (key) => {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(raw);
|
const data = JSON.parse(raw);
|
||||||
|
|
@ -76,34 +76,34 @@ const secureStorage = {
|
||||||
if (typeof value !== "string") return null;
|
if (typeof value !== "string") return null;
|
||||||
return await _deobfuscate(value);
|
return await _deobfuscate(value);
|
||||||
} catch {
|
} catch {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
sessionStorage.removeItem(STORAGE_KEY);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setItem: async (key, value) => {
|
setItem: async (key, value) => {
|
||||||
let data;
|
let data;
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||||
data = raw ? JSON.parse(raw) : {};
|
data = raw ? JSON.parse(raw) : {};
|
||||||
} catch {
|
} catch {
|
||||||
data = {};
|
data = {};
|
||||||
}
|
}
|
||||||
data[key] = await _obfuscate(value);
|
data[key] = await _obfuscate(value);
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||||
},
|
},
|
||||||
removeItem: async (key) => {
|
removeItem: async (key) => {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(raw);
|
const data = JSON.parse(raw);
|
||||||
delete data[key];
|
delete data[key];
|
||||||
if (Object.keys(data).length === 0) {
|
if (Object.keys(data).length === 0) {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
sessionStorage.removeItem(STORAGE_KEY);
|
||||||
} else {
|
} else {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
sessionStorage.removeItem(STORAGE_KEY);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
/**
|
|
||||||
* Shared delivery/delivery-group utilities.
|
|
||||||
* Single source of truth for isPickupOrder, getErrorMessage, normalizeNom, and STATUS_LABELS.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ── Pickup detection ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if the order group is a pickup (self-pickup) order.
|
|
||||||
* Uses canonical deliveryStatus field (mapper normalizes both delivery_status variants).
|
|
||||||
*/
|
|
||||||
export const isPickupOrder = (order) =>
|
|
||||||
order?.deliveryType === "pickup" ||
|
|
||||||
order?.deliveryStatus === "pickup" ||
|
|
||||||
order?.delivery_status === "pickup";
|
|
||||||
|
|
||||||
// ── Error messages ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const getErrorMessage = (error, fallbackMessage) => {
|
|
||||||
if (!error) {
|
|
||||||
return fallbackMessage;
|
|
||||||
}
|
|
||||||
if (error instanceof Error) {
|
|
||||||
return error.message || fallbackMessage;
|
|
||||||
}
|
|
||||||
if (typeof error === "string") {
|
|
||||||
return error || fallbackMessage;
|
|
||||||
}
|
|
||||||
return error?.message || fallbackMessage;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Nom normalisation ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 1C escapes backslashes: "СФ Т\\ЕА-33584" → normalise for comparison.
|
|
||||||
*/
|
|
||||||
export const normalizeNom = (nom) => {
|
|
||||||
if (!nom) return "";
|
|
||||||
return String(nom).replace(/\\\\/g, "\\").trim();
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Status labels ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const DELIVERY_STATUS_LABELS = {
|
|
||||||
pending_confirmation: "Ожидает подтверждения",
|
|
||||||
requires_address: "Требуется адрес",
|
|
||||||
agreed: "Согласовано",
|
|
||||||
driver_assigned: "Водитель назначен",
|
|
||||||
loaded: "Загружен",
|
|
||||||
on_route: "В пути",
|
|
||||||
delivered: "Доставлено",
|
|
||||||
problem: "Проблема",
|
|
||||||
cancelled: "Отменено",
|
|
||||||
pickup: "Самовывоз",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getOrderGroupDeliveryStatusLabel = (status) =>
|
|
||||||
DELIVERY_STATUS_LABELS[status] || status || "Ожидает подтверждения";
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.8";
|
import { createClient } from "@supabase/supabase-js";
|
||||||
import { getOrderUpdateForInboundAction } from "./workflow.ts";
|
import { getOrderUpdateForInboundAction } from "./workflow.ts";
|
||||||
|
|
||||||
export type ProviderName = "telegram" | "vk" | "messenger_max";
|
export type ProviderName = "telegram" | "vk" | "messenger_max";
|
||||||
|
|
|
||||||
|
|
@ -120,25 +120,15 @@ export const normalizeAvailableSlots = (availableSlots?: string[] | null) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildDefaultDatedAvailableSlots = (now = new Date()) => {
|
export const buildDefaultDatedAvailableSlots = (now = new Date()) => {
|
||||||
const CRIMEA_TZ = "Europe/Simferopol";
|
const formatIsoDate = (date: Date) => date.toISOString().slice(0, 10);
|
||||||
|
|
||||||
const formatCrimeaDate = (date: Date) => {
|
|
||||||
return new Intl.DateTimeFormat("en-CA", {
|
|
||||||
timeZone: CRIMEA_TZ,
|
|
||||||
year: "numeric",
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
}).format(date);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addDays = (date: Date, days: number) => {
|
const addDays = (date: Date, days: number) => {
|
||||||
const next = new Date(date);
|
const next = new Date(date);
|
||||||
next.setUTCDate(next.getUTCDate() + days);
|
next.setUTCDate(next.getUTCDate() + days);
|
||||||
return next;
|
return next;
|
||||||
};
|
};
|
||||||
|
|
||||||
const firstDay = formatCrimeaDate(addDays(now, 1));
|
const firstDay = formatIsoDate(addDays(now, 1));
|
||||||
const secondDay = formatCrimeaDate(addDays(now, 2));
|
const secondDay = formatIsoDate(addDays(now, 2));
|
||||||
|
|
||||||
return [
|
return [
|
||||||
`${firstDay}, Первая половина дня`,
|
`${firstDay}, Первая половина дня`,
|
||||||
|
|
|
||||||
|
|
@ -1,172 +1,399 @@
|
||||||
import { createClient } from 'npm:@supabase/supabase-js@2';
|
type CorsMode = "public" | "integration" | "webhook";
|
||||||
|
|
||||||
const ALLOWED_ORIGINS = [
|
type JsonBodyOptions = {
|
||||||
'https://supa.supersamsev.ru',
|
maxBytes: number;
|
||||||
'https://dost.supersamsev.ru',
|
errorMessage?: string;
|
||||||
'http://localhost:5173',
|
};
|
||||||
'http://localhost:5174',
|
|
||||||
'http://localhost:3000',
|
|
||||||
'https://supasevdev.mkn8n.ru',
|
|
||||||
];
|
|
||||||
|
|
||||||
export function createServiceClient() {
|
type RateLimitOptions = {
|
||||||
const supabaseUrl = Deno.env.get('SUPABASE_URL') || '';
|
|
||||||
const serviceRoleKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') || '';
|
|
||||||
return createClient(supabaseUrl, serviceRoleKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getClientIp(request: Request): string {
|
|
||||||
const xff = request.headers.get('x-forwarded-for');
|
|
||||||
if (xff) return xff.split(',')[0].trim();
|
|
||||||
return request.headers.get('x-real-ip') || 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCorsHeaders(request: Request, _access: 'public' | 'private') {
|
|
||||||
const origin = request.headers.get('origin') || '';
|
|
||||||
if (!origin) {
|
|
||||||
return {
|
|
||||||
'Access-Control-Allow-Origin': ALLOWED_ORIGINS[0],
|
|
||||||
'Access-Control-Allow-Methods': 'GET,POST,PATCH,DELETE,OPTIONS',
|
|
||||||
'Access-Control-Allow-Headers': 'Content-Type,Authorization,apikey,x-application-name,x-client-info',
|
|
||||||
'Access-Control-Max-Age': '86400',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const allowed = ALLOWED_ORIGINS.some((o) => origin.startsWith(o));
|
|
||||||
if (!allowed) return null;
|
|
||||||
return {
|
|
||||||
'Access-Control-Allow-Origin': origin,
|
|
||||||
'Access-Control-Allow-Methods': 'GET,POST,PATCH,DELETE,OPTIONS',
|
|
||||||
'Access-Control-Allow-Headers': 'Content-Type,Authorization,apikey,x-application-name,x-client-info',
|
|
||||||
'Access-Control-Max-Age': '86400',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function preflightResponse(request: Request, access: 'public' | 'private') {
|
|
||||||
const corsHeaders = getCorsHeaders(request, access);
|
|
||||||
if (!corsHeaders) {
|
|
||||||
return new Response('Origin not allowed', { status: 403 });
|
|
||||||
}
|
|
||||||
return new Response(null, { status: 204, headers: corsHeaders });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function jsonResponse(body: unknown, status = 200, corsHeaders?: Record<string, string>) {
|
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
||||||
if (corsHeaders) Object.assign(headers, corsHeaders);
|
|
||||||
return new Response(JSON.stringify(body), { status, headers });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function hashText(text: string): Promise<string> {
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
const data = encoder.encode(text);
|
|
||||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
||||||
return Array.from(new Uint8Array(hashBuffer))
|
|
||||||
.map((b) => b.toString(16).padStart(2, '0'))
|
|
||||||
.join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
interface JsonBodyResult<T> {
|
|
||||||
body: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readJsonBody<T>(request: Request, options?: { maxBytes?: number }): Promise<JsonBodyResult<T>> {
|
|
||||||
const maxBytes = options?.maxBytes ?? 1024 * 1024;
|
|
||||||
const reader = request.body?.getReader();
|
|
||||||
if (!reader) throw new Error('No body');
|
|
||||||
const chunks: Uint8Array[] = [];
|
|
||||||
let totalBytes = 0;
|
|
||||||
for (;;) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
totalBytes += value.length;
|
|
||||||
if (totalBytes > maxBytes) {
|
|
||||||
reader.cancel();
|
|
||||||
throw Object.assign(new Error('Request body too large'), { status: 413 });
|
|
||||||
}
|
|
||||||
chunks.push(value);
|
|
||||||
}
|
|
||||||
const combined = new Uint8Array(totalBytes);
|
|
||||||
let offset = 0;
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
combined.set(chunk, offset);
|
|
||||||
offset += chunk.length;
|
|
||||||
}
|
|
||||||
const text = new TextDecoder().decode(combined);
|
|
||||||
const body = JSON.parse(text) as T;
|
|
||||||
return { body };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RateLimitOptions {
|
|
||||||
scope: string;
|
scope: string;
|
||||||
key: string;
|
key: string;
|
||||||
maxCount: number;
|
maxCount: number;
|
||||||
windowSeconds: number;
|
windowSeconds: number;
|
||||||
blockSeconds: number;
|
blockSeconds?: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
class RateLimitError extends Error {
|
type RateLimitResult = {
|
||||||
|
allowed: boolean;
|
||||||
|
currentCount: number;
|
||||||
|
limitCount: number;
|
||||||
|
blockedUntil: string | null;
|
||||||
|
windowStart: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type IntegrationAuthOptions = {
|
||||||
|
rawBody: string;
|
||||||
|
secretEnvNames?: string[];
|
||||||
|
tokenEnvNames?: string[];
|
||||||
|
signatureHeader?: string;
|
||||||
|
timestampHeader?: string;
|
||||||
|
requestIdHeader?: string;
|
||||||
|
allowedClockSkewSeconds?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_LOCAL_ORIGINS = [
|
||||||
|
"http://localhost:5173",
|
||||||
|
"http://localhost:4173",
|
||||||
|
"http://127.0.0.1:5173",
|
||||||
|
"http://127.0.0.1:4173",
|
||||||
|
];
|
||||||
|
|
||||||
|
const normalizeOrigin = (value: string) => value.replace(/\/$/, "");
|
||||||
|
|
||||||
|
const splitList = (value: string | null | undefined) =>
|
||||||
|
(value || "")
|
||||||
|
.split(",")
|
||||||
|
.map((item) => normalizeOrigin(item.trim()))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const getRequestOrigin = (request: Request) => {
|
||||||
|
const origin = request.headers.get("origin");
|
||||||
|
if (origin) {
|
||||||
|
return normalizeOrigin(origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
const referer = request.headers.get("referer");
|
||||||
|
if (!referer) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return normalizeOrigin(new URL(referer).origin);
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const readEnv = (name: string) => {
|
||||||
|
try {
|
||||||
|
if (typeof Deno === "undefined") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return Deno.env.get(name) || "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLocalhostOrigin = (origin: string) =>
|
||||||
|
/:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin);
|
||||||
|
|
||||||
|
const resolveAllowedOrigins = (mode: CorsMode) => {
|
||||||
|
const publicOrigins = [
|
||||||
|
...splitList(readEnv("APP_ALLOWED_ORIGINS")),
|
||||||
|
...splitList(readEnv("PUBLIC_APP_URL")),
|
||||||
|
...splitList(readEnv("APP_PUBLIC_URL")),
|
||||||
|
];
|
||||||
|
const integrationOrigins = [
|
||||||
|
...splitList(readEnv("INTEGRATION_ALLOWED_ORIGINS")),
|
||||||
|
...splitList(readEnv("PUBLIC_APP_URL")),
|
||||||
|
];
|
||||||
|
const webhookOrigins = [
|
||||||
|
...splitList(readEnv("WEBHOOK_ALLOWED_ORIGINS")),
|
||||||
|
...splitList(readEnv("PUBLIC_APP_URL")),
|
||||||
|
];
|
||||||
|
|
||||||
|
const configured =
|
||||||
|
mode === "public"
|
||||||
|
? publicOrigins
|
||||||
|
: mode === "integration"
|
||||||
|
? integrationOrigins
|
||||||
|
: webhookOrigins;
|
||||||
|
|
||||||
|
if (configured.length > 0) {
|
||||||
|
return Array.from(new Set(configured));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
export class HttpError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
constructor(message: string, status: number) {
|
|
||||||
|
constructor(status: number, message: string) {
|
||||||
super(message);
|
super(message);
|
||||||
this.status = status;
|
this.status = status;
|
||||||
|
this.name = "HttpError";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requireRateLimit(supabase: ReturnType<typeof createClient>, options: RateLimitOptions) {
|
export const jsonResponse = (
|
||||||
const { scope, key, maxCount, windowSeconds, blockSeconds } = options;
|
body: unknown,
|
||||||
const tableName = 'rate_limits';
|
status = 200,
|
||||||
const now = new Date();
|
headers: HeadersInit = {},
|
||||||
|
) =>
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const { data: blocked } = await supabase
|
export const getCorsHeaders = (request: Request, mode: CorsMode) => {
|
||||||
.from(tableName)
|
const origin = getRequestOrigin(request);
|
||||||
.select('blocked_until')
|
const allowedOrigins = resolveAllowedOrigins(mode);
|
||||||
.eq('scope', scope)
|
|
||||||
.eq('rate_key', key)
|
|
||||||
.gt('blocked_until', now.toISOString())
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (blocked && blocked.length > 0) {
|
if (!origin) {
|
||||||
throw new RateLimitError('Too many requests. Please try again later.', 429);
|
if (allowedOrigins.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-request-id, x-signature, x-timestamp, x-webhook-secret",
|
||||||
|
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||||
|
"Access-Control-Max-Age": "86400",
|
||||||
|
Vary: "Origin",
|
||||||
|
} satisfies Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const windowStart = new Date(now.getTime() - windowSeconds * 1000);
|
const isAllowed =
|
||||||
const { data: recent, error } = await supabase
|
allowedOrigins.length === 0
|
||||||
.from(tableName)
|
? false
|
||||||
.select('id, count')
|
: allowedOrigins.some((allowedOrigin) => {
|
||||||
.eq('scope', scope)
|
if (allowedOrigin === "*") {
|
||||||
.eq('rate_key', key)
|
return true;
|
||||||
.gte('window_start', windowStart.toISOString());
|
}
|
||||||
|
|
||||||
|
return origin === allowedOrigin || origin.startsWith(`${allowedOrigin}/`);
|
||||||
|
}) || (!readEnv("NODE_ENV") || readEnv("NODE_ENV") !== "production" && isLocalhostOrigin(origin));
|
||||||
|
|
||||||
|
if (!isAllowed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"Access-Control-Allow-Origin": origin,
|
||||||
|
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-request-id, x-signature, x-timestamp, x-webhook-secret",
|
||||||
|
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||||
|
"Access-Control-Max-Age": "86400",
|
||||||
|
Vary: "Origin",
|
||||||
|
} satisfies Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const preflightResponse = (request: Request, mode: CorsMode) => {
|
||||||
|
const corsHeaders = getCorsHeaders(request, mode);
|
||||||
|
if (!corsHeaders) {
|
||||||
|
return jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("ok", {
|
||||||
|
status: 204,
|
||||||
|
headers: corsHeaders,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const assertAllowedOrigin = (request: Request, mode: CorsMode) => {
|
||||||
|
const corsHeaders = getCorsHeaders(request, mode);
|
||||||
|
if (!corsHeaders) {
|
||||||
|
throw new HttpError(403, "Origin not allowed");
|
||||||
|
}
|
||||||
|
|
||||||
|
return corsHeaders;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const readJsonBody = async <T extends Record<string, unknown>>(
|
||||||
|
request: Request,
|
||||||
|
options: JsonBodyOptions,
|
||||||
|
): Promise<{ body: T; rawBody: string }> => {
|
||||||
|
const rawBody = await request.clone().text();
|
||||||
|
const byteLength = new TextEncoder().encode(rawBody).length;
|
||||||
|
|
||||||
|
if (byteLength > options.maxBytes) {
|
||||||
|
throw new HttpError(413, options.errorMessage || "Payload too large");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rawBody.trim()) {
|
||||||
|
throw new HttpError(400, "Request body is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
body: JSON.parse(rawBody) as T,
|
||||||
|
rawBody,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
throw new HttpError(400, "Invalid JSON payload");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getClientIp = (request: Request) => {
|
||||||
|
const forwardedFor = request.headers.get("x-forwarded-for") || request.headers.get("cf-connecting-ip") || request.headers.get("x-real-ip") || "";
|
||||||
|
return forwardedFor.split(",")[0]?.trim() || "unknown";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sha256Hex = async (value: string) => {
|
||||||
|
const bytes = new TextEncoder().encode(value);
|
||||||
|
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
||||||
|
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hashText = sha256Hex;
|
||||||
|
|
||||||
|
const hmacHex = async (secret: string, value: string) => {
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
new TextEncoder().encode(secret),
|
||||||
|
{ name: "HMAC", hash: "SHA-256" },
|
||||||
|
false,
|
||||||
|
["sign"],
|
||||||
|
);
|
||||||
|
const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(value));
|
||||||
|
return [...new Uint8Array(signature)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifyInternalRequest = async (
|
||||||
|
request: Request,
|
||||||
|
rawBody: string,
|
||||||
|
options: IntegrationAuthOptions = { rawBody },
|
||||||
|
) => {
|
||||||
|
const tokenEnvNames = options.tokenEnvNames || ["INTEGRATION_API_KEY", "INTERNAL_API_KEY"];
|
||||||
|
const secretEnvNames = options.secretEnvNames || ["INTEGRATION_WEBHOOK_SECRET", "CHATBOT_WEBHOOK_SECRET"];
|
||||||
|
const bearerToken = request.headers.get("authorization") || "";
|
||||||
|
const token = bearerToken.toLowerCase().startsWith("bearer ") ? bearerToken.slice(7).trim() : "";
|
||||||
|
const requestId = request.headers.get(options.requestIdHeader || "x-request-id") || "";
|
||||||
|
const timestamp = request.headers.get(options.timestampHeader || "x-timestamp") || "";
|
||||||
|
const signature = request.headers.get(options.signatureHeader || "x-signature") || "";
|
||||||
|
const sharedTokens = tokenEnvNames.map((name) => readEnv(name)).filter(Boolean);
|
||||||
|
const sharedSecrets = secretEnvNames.map((name) => readEnv(name)).filter(Boolean);
|
||||||
|
|
||||||
|
if (token && sharedTokens.some((candidate) => candidate === token)) {
|
||||||
|
return { requestId, authenticatedBy: "bearer" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sharedSecrets.length === 0) {
|
||||||
|
throw new HttpError(401, "Integration auth is not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!timestamp || !signature) {
|
||||||
|
throw new HttpError(401, "Missing integration signature");
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestampNumber = Number(timestamp);
|
||||||
|
if (!Number.isFinite(timestampNumber)) {
|
||||||
|
throw new HttpError(401, "Invalid integration timestamp");
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const allowedSkew = (options.allowedClockSkewSeconds || 300) * 1000;
|
||||||
|
if (Math.abs(now - timestampNumber) > allowedSkew) {
|
||||||
|
throw new HttpError(401, "Stale integration request");
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = `${timestamp}.${rawBody}`;
|
||||||
|
const expectedSignatures = await Promise.all(
|
||||||
|
sharedSecrets.map(async (secret) => hmacHex(secret, payload)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!expectedSignatures.some((candidate) => candidate === signature)) {
|
||||||
|
throw new HttpError(401, "Invalid integration signature");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { requestId, authenticatedBy: "hmac" as const };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const maskPhoneNumber = (phone: string | null | undefined) => {
|
||||||
|
const value = String(phone || "").trim();
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const digits = value.replace(/\D/g, "");
|
||||||
|
if (digits.length < 4) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tail = digits.slice(-4);
|
||||||
|
const country = digits.startsWith("7") || digits.startsWith("8") ? "+7" : "+";
|
||||||
|
return `${country} *** ***-${tail.slice(0, 2)}-${tail.slice(2)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const maskCustomerName = (name: string | null | undefined) => {
|
||||||
|
const value = String(name || "").trim();
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = value.split(/\s+/).filter(Boolean);
|
||||||
|
if (parts.length === 1) {
|
||||||
|
return `${parts[0].slice(0, 1)}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${parts[0]} ${parts[1].slice(0, 1)}.`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const maskOrderNumber = (orderNumber: string | null | undefined) => {
|
||||||
|
const value = String(orderNumber || "").trim();
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.length <= 4) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `…${value.slice(-4)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const isValidUuid = (value: string): boolean => {
|
||||||
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const requireUuid = (value: string | undefined | null, label = "id"): string => {
|
||||||
|
const trimmed = (value || "").trim();
|
||||||
|
if (!trimmed || !isValidUuid(trimmed)) {
|
||||||
|
throw new HttpError(400, `Invalid ${label} format`);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const requireSameOrigin = (request: Request, allowedOrigins: string[]) => {
|
||||||
|
const origin = request.headers.get("origin") || "";
|
||||||
|
const host = request.headers.get("host") || "";
|
||||||
|
if (!origin || !host) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const originHost = new URL(origin).host;
|
||||||
|
return allowedOrigins.some((allowed) => {
|
||||||
|
try {
|
||||||
|
return new URL(allowed).host === originHost;
|
||||||
|
} catch {
|
||||||
|
return allowed === origin;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
export const requireRateLimit = async (
|
||||||
|
supabase: {
|
||||||
|
rpc: (
|
||||||
|
name: string,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
) => PromiseLike<{ data: RateLimitResult | null; error: Error | null }>;
|
||||||
|
},
|
||||||
|
options: RateLimitOptions,
|
||||||
|
) => {
|
||||||
|
const { data, error } = await supabase.rpc("check_rate_limit", {
|
||||||
|
p_scope: options.scope,
|
||||||
|
p_key: options.key,
|
||||||
|
p_max_count: options.maxCount,
|
||||||
|
p_window_seconds: options.windowSeconds,
|
||||||
|
p_block_seconds: options.blockSeconds || 0,
|
||||||
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Rate limit check error:', error);
|
throw error;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalCount = recent?.reduce((sum: number, r: { count: number }) => sum + r.count, 0) ?? 0;
|
if (!data?.allowed) {
|
||||||
|
throw new HttpError(429, "Too many requests");
|
||||||
if (totalCount >= maxCount) {
|
|
||||||
const blockedUntil = new Date(now.getTime() + blockSeconds * 1000);
|
|
||||||
await supabase
|
|
||||||
.from(tableName)
|
|
||||||
.update({ blocked_until: blockedUntil.toISOString() })
|
|
||||||
.eq('scope', scope)
|
|
||||||
.eq('rate_key', key)
|
|
||||||
.gte('window_start', windowStart.toISOString());
|
|
||||||
throw new RateLimitError('Too many requests. Please try again later.', 429);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingRow = recent?.[0];
|
return data;
|
||||||
if (existingRow) {
|
};
|
||||||
await supabase
|
|
||||||
.from(tableName)
|
|
||||||
.update({ count: (existingRow as { count: number }).count + 1 })
|
|
||||||
.eq('id', (existingRow as { id: string }).id);
|
|
||||||
} else {
|
|
||||||
await supabase.from(tableName).insert({
|
|
||||||
scope,
|
|
||||||
rate_key: key,
|
|
||||||
window_start: now.toISOString(),
|
|
||||||
count: 1,
|
|
||||||
blocked_until: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,6 @@ type ConfirmBody = {
|
||||||
token?: string;
|
token?: string;
|
||||||
deliveryDate?: string;
|
deliveryDate?: string;
|
||||||
deliveryTime?: string;
|
deliveryTime?: string;
|
||||||
deliveryType?: string;
|
|
||||||
pickupDate?: string;
|
|
||||||
pickupTimeSlot?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
|
const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
|
||||||
|
|
@ -39,7 +36,6 @@ const resolveRequestedSlot = (
|
||||||
},
|
},
|
||||||
body: ConfirmBody,
|
body: ConfirmBody,
|
||||||
) => {
|
) => {
|
||||||
const deliveryType = body.deliveryType || "delivery";
|
|
||||||
const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim();
|
const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim();
|
||||||
const deliveryTime = String(body.deliveryTime || invitation.delivery_time || "").trim();
|
const deliveryTime = String(body.deliveryTime || invitation.delivery_time || "").trim();
|
||||||
|
|
||||||
|
|
@ -47,11 +43,6 @@ const resolveRequestedSlot = (
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For pickup, we allow slots outside the invitation's available_slots
|
|
||||||
if (deliveryType === "pickup") {
|
|
||||||
return { deliveryDate, deliveryTime, deliveryType };
|
|
||||||
}
|
|
||||||
|
|
||||||
const slotLabel = `${deliveryDate}, ${deliveryTime}`;
|
const slotLabel = `${deliveryDate}, ${deliveryTime}`;
|
||||||
const availableSlots = invitation.available_slots || [];
|
const availableSlots = invitation.available_slots || [];
|
||||||
|
|
||||||
|
|
@ -59,7 +50,7 @@ const resolveRequestedSlot = (
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { deliveryDate, deliveryTime, deliveryType };
|
return { deliveryDate, deliveryTime };
|
||||||
};
|
};
|
||||||
|
|
||||||
Deno.serve(async (request) => {
|
Deno.serve(async (request) => {
|
||||||
|
|
@ -136,16 +127,6 @@ Deno.serve(async (request) => {
|
||||||
return jsonResponse({ ok: false, error: "Invitation expired" }, 410, corsHeaders);
|
return jsonResponse({ ok: false, error: "Invitation expired" }, 410, corsHeaders);
|
||||||
}
|
}
|
||||||
|
|
||||||
const deliveryType = body.deliveryType || "delivery";
|
|
||||||
|
|
||||||
// When user switches from pickup to delivery but has no address → requires_address
|
|
||||||
const hasAddress = invitation.delivery_address?.trim() || currentGroup?.delivery_address?.trim() || currentGroup?.customer_address?.trim();
|
|
||||||
const effectiveDeliveryStatus = deliveryType === "pickup"
|
|
||||||
? "pickup"
|
|
||||||
: hasAddress
|
|
||||||
? "agreed"
|
|
||||||
: "requires_address";
|
|
||||||
|
|
||||||
if (invitation.order_group_id) {
|
if (invitation.order_group_id) {
|
||||||
const { data: currentGroup, error: groupError } = await supabase
|
const { data: currentGroup, error: groupError } = await supabase
|
||||||
.from("order_groups")
|
.from("order_groups")
|
||||||
|
|
@ -196,23 +177,15 @@ Deno.serve(async (request) => {
|
||||||
throw invitationUpdateError;
|
throw invitationUpdateError;
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupUpdateData: Record<string, unknown> = {
|
|
||||||
delivery_status: effectiveDeliveryStatus,
|
|
||||||
delivery_date: requestedSlot.deliveryDate,
|
|
||||||
delivery_time: requestedSlot.deliveryTime,
|
|
||||||
delivery_type: deliveryType,
|
|
||||||
notification_status: effectiveDeliveryStatus === "requires_address" ? "address_required" : "confirmed",
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (deliveryType === "pickup") {
|
|
||||||
groupUpdateData.pickup_date = body.pickupDate || requestedSlot.deliveryDate || null;
|
|
||||||
groupUpdateData.pickup_time_slot = body.pickupTimeSlot || requestedSlot.deliveryTime || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error: groupUpdateError } = await supabase
|
const { error: groupUpdateError } = await supabase
|
||||||
.from("order_groups")
|
.from("order_groups")
|
||||||
.update(groupUpdateData)
|
.update({
|
||||||
|
delivery_status: "agreed",
|
||||||
|
delivery_date: requestedSlot.deliveryDate,
|
||||||
|
delivery_time: requestedSlot.deliveryTime,
|
||||||
|
notification_status: "confirmed",
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
.eq("id", invitation.order_group_id);
|
.eq("id", invitation.order_group_id);
|
||||||
|
|
||||||
if (groupUpdateError) {
|
if (groupUpdateError) {
|
||||||
|
|
@ -224,13 +197,10 @@ Deno.serve(async (request) => {
|
||||||
order_group_id: invitation.order_group_id,
|
order_group_id: invitation.order_group_id,
|
||||||
action: "client_confirmed",
|
action: "client_confirmed",
|
||||||
old_value: currentGroup.delivery_status,
|
old_value: currentGroup.delivery_status,
|
||||||
new_value: effectiveDeliveryStatus,
|
new_value: "agreed",
|
||||||
details: {
|
details: {
|
||||||
delivery_date: requestedSlot.deliveryDate,
|
delivery_date: requestedSlot.deliveryDate,
|
||||||
delivery_time: requestedSlot.deliveryTime,
|
delivery_time: requestedSlot.deliveryTime,
|
||||||
delivery_type: deliveryType,
|
|
||||||
pickup_date: body.pickupDate || null,
|
|
||||||
pickup_time_slot: body.pickupTimeSlot || null,
|
|
||||||
source: "auto",
|
source: "auto",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -245,9 +215,6 @@ Deno.serve(async (request) => {
|
||||||
delivery_invitation_id: invitation.id,
|
delivery_invitation_id: invitation.id,
|
||||||
delivery_date: requestedSlot.deliveryDate,
|
delivery_date: requestedSlot.deliveryDate,
|
||||||
delivery_time: requestedSlot.deliveryTime,
|
delivery_time: requestedSlot.deliveryTime,
|
||||||
delivery_type: deliveryType,
|
|
||||||
pickup_date: body.pickupDate || null,
|
|
||||||
pickup_time_slot: body.pickupTimeSlot || null,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -255,7 +222,7 @@ Deno.serve(async (request) => {
|
||||||
{
|
{
|
||||||
ok: true,
|
ok: true,
|
||||||
orderGroupId: invitation.order_group_id,
|
orderGroupId: invitation.order_group_id,
|
||||||
deliveryStatus: effectiveDeliveryStatus,
|
deliveryStatus: "agreed",
|
||||||
},
|
},
|
||||||
200,
|
200,
|
||||||
corsHeaders,
|
corsHeaders,
|
||||||
|
|
@ -347,9 +314,6 @@ Deno.serve(async (request) => {
|
||||||
new_delivery_agreement_status: orderUpdate?.deliveryAgreementStatus,
|
new_delivery_agreement_status: orderUpdate?.deliveryAgreementStatus,
|
||||||
delivery_date: requestedSlot.deliveryDate,
|
delivery_date: requestedSlot.deliveryDate,
|
||||||
delivery_time: requestedSlot.deliveryTime,
|
delivery_time: requestedSlot.deliveryTime,
|
||||||
delivery_type: deliveryType,
|
|
||||||
pickup_date: body.pickupDate || null,
|
|
||||||
pickup_time_slot: body.pickupTimeSlot || null,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -365,9 +329,6 @@ Deno.serve(async (request) => {
|
||||||
payload: {
|
payload: {
|
||||||
delivery_date: requestedSlot.deliveryDate,
|
delivery_date: requestedSlot.deliveryDate,
|
||||||
delivery_time: requestedSlot.deliveryTime,
|
delivery_time: requestedSlot.deliveryTime,
|
||||||
delivery_type: deliveryType,
|
|
||||||
pickup_date: body.pickupDate || null,
|
|
||||||
pickup_time_slot: body.pickupTimeSlot || null,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,168 +0,0 @@
|
||||||
import * as jose from 'https://deno.land/x/jose@v4.14.4/index.ts'
|
|
||||||
|
|
||||||
console.log('main function started')
|
|
||||||
|
|
||||||
const JWT_SECRET = Deno.env.get('JWT_SECRET')
|
|
||||||
const SUPABASE_URL = Deno.env.get('SUPABASE_URL')
|
|
||||||
const VERIFY_JWT = Deno.env.get('VERIFY_JWT') === 'true'
|
|
||||||
|
|
||||||
// Create JWKS for ES256/RS256 tokens (newer tokens)
|
|
||||||
let SUPABASE_JWT_KEYS: ReturnType<typeof jose.createRemoteJWKSet> | null = null
|
|
||||||
if (SUPABASE_URL) {
|
|
||||||
try {
|
|
||||||
SUPABASE_JWT_KEYS = jose.createRemoteJWKSet(
|
|
||||||
new URL('/auth/v1/.well-known/jwks.json', SUPABASE_URL)
|
|
||||||
)
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to fetch JWKS from SUPABASE_URL:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract JWT token from Authorization header
|
|
||||||
*
|
|
||||||
* Parses the Authorization header to extract the Bearer token.
|
|
||||||
* Expects format: "Bearer <token>"
|
|
||||||
*
|
|
||||||
* @param req - The HTTP request object
|
|
||||||
* @returns The JWT token string
|
|
||||||
* @throws Error if Authorization header is missing or malformed
|
|
||||||
*/
|
|
||||||
function getAuthToken(req: Request) {
|
|
||||||
const authHeader = req.headers.get('authorization')
|
|
||||||
if (!authHeader) {
|
|
||||||
throw new Error('Missing authorization header')
|
|
||||||
}
|
|
||||||
const [bearer, token] = authHeader.split(' ')
|
|
||||||
if (bearer !== 'Bearer') {
|
|
||||||
throw new Error(`Auth header is not 'Bearer {token}'`)
|
|
||||||
}
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
|
|
||||||
async function isValidLegacyJWT(jwt: string): Promise<boolean> {
|
|
||||||
if (!JWT_SECRET) {
|
|
||||||
console.error('JWT_SECRET not available for HS256 token verification')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
const secretKey = encoder.encode(JWT_SECRET)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await jose.jwtVerify(jwt, secretKey);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Symmetric Legacy JWT verification error', e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function isValidJWT(jwt: string): Promise<boolean> {
|
|
||||||
if (!SUPABASE_JWT_KEYS) {
|
|
||||||
console.error('JWKS not available for ES256/RS256 token verification')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await jose.jwtVerify(jwt, SUPABASE_JWT_KEYS)
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Asymmetric JWT verification error', e);
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify JWT token, handling both legacy (HS256) and newer (ES256/RS256) algorithms
|
|
||||||
*
|
|
||||||
* This function automatically detects the algorithm used in the token and applies
|
|
||||||
* the appropriate verification method:
|
|
||||||
* - HS256: Uses JWT_SECRET (symmetric key)
|
|
||||||
* - ES256/RS256: Uses JWKS endpoint (asymmetric public keys)
|
|
||||||
*
|
|
||||||
* This fix ensures compatibility with both legacy tokens and newer asymmetric tokens,
|
|
||||||
* resolving the "Key for the ES256 algorithm must be of type CryptoKey" error.
|
|
||||||
*
|
|
||||||
* @param jwt - The JWT token string to verify
|
|
||||||
* @returns Promise resolving to true if verification succeeds, false otherwise
|
|
||||||
*/
|
|
||||||
async function isValidHybridJWT(jwt: string): Promise<boolean> {
|
|
||||||
const { alg: jwtAlgorithm } = jose.decodeProtectedHeader(jwt)
|
|
||||||
|
|
||||||
if (jwtAlgorithm === 'HS256') {
|
|
||||||
console.log(`Legacy token type detected, attempting ${jwtAlgorithm} verification.`)
|
|
||||||
|
|
||||||
return await isValidLegacyJWT(jwt)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (jwtAlgorithm === 'ES256' || jwtAlgorithm === 'RS256') {
|
|
||||||
return await isValidJWT(jwt)
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
Deno.serve(async (req: Request) => {
|
|
||||||
if (req.method !== 'OPTIONS' && VERIFY_JWT) {
|
|
||||||
try {
|
|
||||||
const token = getAuthToken(req)
|
|
||||||
const isValidJWT = await isValidHybridJWT(token);
|
|
||||||
|
|
||||||
if (!isValidJWT) {
|
|
||||||
return new Response(JSON.stringify({ msg: 'Invalid JWT' }), {
|
|
||||||
status: 401,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
return new Response(JSON.stringify({ msg: e.toString() }), {
|
|
||||||
status: 401,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = new URL(req.url)
|
|
||||||
const { pathname } = url
|
|
||||||
const path_parts = pathname.split('/')
|
|
||||||
const service_name = path_parts[1]
|
|
||||||
|
|
||||||
if (!service_name || service_name === '') {
|
|
||||||
const error = { msg: 'missing function name in request' }
|
|
||||||
return new Response(JSON.stringify(error), {
|
|
||||||
status: 400,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const servicePath = `/home/deno/functions/${service_name}`
|
|
||||||
console.error(`serving the request with ${servicePath}`)
|
|
||||||
|
|
||||||
const memoryLimitMb = 150
|
|
||||||
const workerTimeoutMs = 1 * 60 * 1000
|
|
||||||
const noModuleCache = false
|
|
||||||
const importMapPath = "/home/deno/functions/import_map.json"
|
|
||||||
const envVarsObj = Deno.env.toObject()
|
|
||||||
const envVars = Object.keys(envVarsObj).map((k) => [k, envVarsObj[k]])
|
|
||||||
|
|
||||||
try {
|
|
||||||
const worker = await EdgeRuntime.userWorkers.create({
|
|
||||||
servicePath,
|
|
||||||
memoryLimitMb,
|
|
||||||
workerTimeoutMs,
|
|
||||||
noModuleCache,
|
|
||||||
importMapPath,
|
|
||||||
envVars,
|
|
||||||
})
|
|
||||||
return await worker.fetch(req)
|
|
||||||
} catch (e) {
|
|
||||||
const error = { msg: e.toString() }
|
|
||||||
return new Response(JSON.stringify(error), {
|
|
||||||
status: 500,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { createServiceClient } from "../_shared/security.ts";
|
import { createAnonClient } from "../_shared/chatbot.ts";
|
||||||
import {
|
import {
|
||||||
getClientIp,
|
getClientIp,
|
||||||
getCorsHeaders,
|
getCorsHeaders,
|
||||||
|
|
@ -14,17 +14,6 @@ const MAX_BODY_BYTES = 8 * 1024;
|
||||||
const isValidEmail = (value: string) =>
|
const isValidEmail = (value: string) =>
|
||||||
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
|
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
|
||||||
|
|
||||||
function generateOtp(): string {
|
|
||||||
const digits = "0123456789";
|
|
||||||
let otp = "";
|
|
||||||
const arr = new Uint8Array(6);
|
|
||||||
crypto.getRandomValues(arr);
|
|
||||||
for (let i = 0; i < 6; i++) {
|
|
||||||
otp += digits[arr[i] % digits.length];
|
|
||||||
}
|
|
||||||
return otp;
|
|
||||||
}
|
|
||||||
|
|
||||||
Deno.serve(async (request) => {
|
Deno.serve(async (request) => {
|
||||||
if (request.method === "OPTIONS") {
|
if (request.method === "OPTIONS") {
|
||||||
return preflightResponse(request, "public");
|
return preflightResponse(request, "public");
|
||||||
|
|
@ -49,7 +38,7 @@ Deno.serve(async (request) => {
|
||||||
return jsonResponse({ ok: false, error: "Valid email is required" }, 400, corsHeaders);
|
return jsonResponse({ ok: false, error: "Valid email is required" }, 400, corsHeaders);
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabase = createServiceClient();
|
const supabase = createAnonClient();
|
||||||
const emailHash = await hashText(email);
|
const emailHash = await hashText(email);
|
||||||
const ipHash = await hashText(getClientIp(request));
|
const ipHash = await hashText(getClientIp(request));
|
||||||
|
|
||||||
|
|
@ -61,50 +50,15 @@ Deno.serve(async (request) => {
|
||||||
blockSeconds: 1800,
|
blockSeconds: 1800,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if user exists in our users table
|
const { error } = await supabase.auth.signInWithOtp({
|
||||||
const { data: users, error: userError } = await supabase
|
|
||||||
.from("users")
|
|
||||||
.select("id, name, roles(name)")
|
|
||||||
.eq("email", email)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (userError || !users || users.length === 0) {
|
|
||||||
return jsonResponse({ ok: false, error: "Email не найден в системе. Обратитесь к администратору." }, 400, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = users[0];
|
|
||||||
const userName = user.name || null;
|
|
||||||
const userRole = user.roles?.name || null;
|
|
||||||
|
|
||||||
// Invalidate previous unverified OTPs for this email
|
|
||||||
await supabase
|
|
||||||
.from("login_otps")
|
|
||||||
.delete()
|
|
||||||
.eq("email", email)
|
|
||||||
.eq("verified", false);
|
|
||||||
|
|
||||||
// Generate OTP
|
|
||||||
const otp = generateOtp();
|
|
||||||
const otpCodeHash = await hashText(otp);
|
|
||||||
const clientIp = getClientIp(request);
|
|
||||||
const userAgent = request.headers.get("user-agent") || null;
|
|
||||||
|
|
||||||
// Insert with plaintext otp_code so DB webhook "send_pin" delivers it to n8n
|
|
||||||
// n8n will clear otp_code after sending SMS
|
|
||||||
const { error: insertError } = await supabase.from("login_otps").insert({
|
|
||||||
email,
|
email,
|
||||||
name: userName,
|
options: {
|
||||||
role: userRole,
|
shouldCreateUser: false,
|
||||||
otp_code: otp,
|
},
|
||||||
otp_code_hash: otpCodeHash,
|
|
||||||
ip_address: clientIp,
|
|
||||||
user_agent: userAgent,
|
|
||||||
verified: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (insertError) {
|
if (error) {
|
||||||
console.error("Failed to insert OTP:", insertError);
|
return jsonResponse({ ok: false, error: error.message }, 400, corsHeaders);
|
||||||
return jsonResponse({ ok: false, error: "Failed to generate OTP" }, 500, corsHeaders);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return jsonResponse({ ok: true }, 200, corsHeaders);
|
return jsonResponse({ ok: true }, 200, corsHeaders);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { createServiceClient } from "../_shared/security.ts";
|
import { createAnonClient } from "../_shared/chatbot.ts";
|
||||||
import {
|
import {
|
||||||
getClientIp,
|
getClientIp,
|
||||||
getCorsHeaders,
|
getCorsHeaders,
|
||||||
|
|
@ -7,10 +7,10 @@ import {
|
||||||
preflightResponse,
|
preflightResponse,
|
||||||
readJsonBody,
|
readJsonBody,
|
||||||
requireRateLimit,
|
requireRateLimit,
|
||||||
|
requireSameOrigin,
|
||||||
} from "../_shared/security.ts";
|
} from "../_shared/security.ts";
|
||||||
|
|
||||||
const MAX_BODY_BYTES = 8 * 1024;
|
const MAX_BODY_BYTES = 8 * 1024;
|
||||||
const OTP_EXPIRY_SECONDS = 600; // 10 minutes
|
|
||||||
|
|
||||||
const isValidEmail = (value: string) =>
|
const isValidEmail = (value: string) =>
|
||||||
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
|
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
|
||||||
|
|
@ -29,6 +29,19 @@ Deno.serve(async (request) => {
|
||||||
return jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
|
return jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allowedOriginsForCsrf = ((): string[] => {
|
||||||
|
const envOrigins = (Deno.env.get("APP_ALLOWED_ORIGINS") || "").split(",").map((s: string) => s.trim()).filter(Boolean);
|
||||||
|
const appUrl = Deno.env.get("PUBLIC_APP_URL") || Deno.env.get("APP_PUBLIC_URL") || "";
|
||||||
|
return [...envOrigins, appUrl].filter(Boolean);
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (!requireSameOrigin(request, allowedOriginsForCsrf)) {
|
||||||
|
const origin = request.headers.get("origin") || "";
|
||||||
|
if (origin) {
|
||||||
|
return jsonResponse({ ok: false, error: "Cross-origin request not allowed" }, 403, corsHeaders);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { body } = await readJsonBody<{ email?: string; otp?: string }>(request, {
|
const { body } = await readJsonBody<{ email?: string; otp?: string }>(request, {
|
||||||
maxBytes: MAX_BODY_BYTES,
|
maxBytes: MAX_BODY_BYTES,
|
||||||
|
|
@ -44,7 +57,7 @@ Deno.serve(async (request) => {
|
||||||
return jsonResponse({ ok: false, error: "Valid OTP is required" }, 400, corsHeaders);
|
return jsonResponse({ ok: false, error: "Valid OTP is required" }, 400, corsHeaders);
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabase = createServiceClient();
|
const supabase = createAnonClient();
|
||||||
const emailHash = await hashText(email);
|
const emailHash = await hashText(email);
|
||||||
const ipHash = await hashText(getClientIp(request));
|
const ipHash = await hashText(getClientIp(request));
|
||||||
|
|
||||||
|
|
@ -56,118 +69,21 @@ Deno.serve(async (request) => {
|
||||||
blockSeconds: 1800,
|
blockSeconds: 1800,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 1. Find the most recent unverified OTP for this email
|
const { data, error } = await supabase.auth.verifyOtp({
|
||||||
const { data: otpRecords, error: fetchError } = await supabase
|
|
||||||
.from("login_otps")
|
|
||||||
.select("*")
|
|
||||||
.eq("email", email)
|
|
||||||
.eq("verified", false)
|
|
||||||
.order("created_at", { ascending: false })
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (fetchError || !otpRecords || otpRecords.length === 0) {
|
|
||||||
return jsonResponse({ ok: false, error: "Неверный или просроченный код" }, 400, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
const otpRecord = otpRecords[0];
|
|
||||||
|
|
||||||
// 2. Check expiry (10 minutes)
|
|
||||||
const createdAt = new Date(otpRecord.created_at);
|
|
||||||
const now = new Date();
|
|
||||||
const elapsedSeconds = (now.getTime() - createdAt.getTime()) / 1000;
|
|
||||||
|
|
||||||
if (elapsedSeconds > OTP_EXPIRY_SECONDS) {
|
|
||||||
await supabase.from("login_otps").delete().eq("id", otpRecord.id);
|
|
||||||
return jsonResponse({ ok: false, error: "Код истёк. Запросите новый." }, 400, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Verify OTP — compare hash (new) with fallback to plaintext (old records)
|
|
||||||
const submittedOtpHash = await hashText(otp);
|
|
||||||
let otpMatches = false;
|
|
||||||
|
|
||||||
if (otpRecord.otp_code_hash) {
|
|
||||||
// New flow: compare SHA-256 hashes
|
|
||||||
otpMatches = otpRecord.otp_code_hash === submittedOtpHash;
|
|
||||||
} else if (otpRecord.otp_code) {
|
|
||||||
// Legacy fallback: plaintext comparison for old records
|
|
||||||
otpMatches = otpRecord.otp_code === otp;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!otpMatches) {
|
|
||||||
return jsonResponse({ ok: false, error: "Неверный код" }, 400, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Mark as verified and clear plaintext if present
|
|
||||||
await supabase
|
|
||||||
.from("login_otps")
|
|
||||||
.update({ verified: true, otp_code: "" })
|
|
||||||
.eq("id", otpRecord.id);
|
|
||||||
|
|
||||||
// Delete all other unverified OTPs for this email
|
|
||||||
await supabase
|
|
||||||
.from("login_otps")
|
|
||||||
.delete()
|
|
||||||
.eq("email", email)
|
|
||||||
.eq("verified", false);
|
|
||||||
|
|
||||||
// 5. Find user by email to get user_id
|
|
||||||
const { data: users } = await supabase
|
|
||||||
.from("users")
|
|
||||||
.select("id, name, roles(name)")
|
|
||||||
.eq("email", email)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!users || users.length === 0) {
|
|
||||||
return jsonResponse({ ok: false, error: "Пользователь не найден" }, 400, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = users[0].id;
|
|
||||||
const userName = users[0].name || null;
|
|
||||||
const userRole = users[0].roles?.name || null;
|
|
||||||
|
|
||||||
// Update the login_otps record with user info
|
|
||||||
await supabase
|
|
||||||
.from("login_otps")
|
|
||||||
.update({ name: userName, role: userRole })
|
|
||||||
.eq("id", otpRecord.id);
|
|
||||||
|
|
||||||
// 6. Create session using Supabase admin API
|
|
||||||
const { data: linkData, error: linkError } = await supabase.auth.admin.generateLink({
|
|
||||||
type: "magiclink",
|
|
||||||
email,
|
email,
|
||||||
|
token: otp,
|
||||||
|
type: "email",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (linkError || !linkData) {
|
if (error) {
|
||||||
console.error("generateLink error:", linkError);
|
return jsonResponse({ ok: false, error: error.message }, 400, corsHeaders);
|
||||||
return jsonResponse({ ok: false, error: "Ошибка авторизации" }, 500, corsHeaders);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const generatedLink = linkData as any;
|
|
||||||
const tokenHash = generatedLink.properties?.hashed_token || generatedLink.properties?.token_hash;
|
|
||||||
|
|
||||||
if (!tokenHash) {
|
|
||||||
console.error("No token in generateLink response");
|
|
||||||
return jsonResponse({ ok: false, error: "Ошибка авторизации" }, 500, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: verifyData, error: verifyError } = await supabase.auth.verifyOtp({
|
|
||||||
type: "magiclink",
|
|
||||||
token_hash: tokenHash,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (verifyError) {
|
|
||||||
console.error("verifyOtp error:", verifyError);
|
|
||||||
return jsonResponse({ ok: false, error: "Ошибка авторизации" }, 500, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = verifyData.session;
|
|
||||||
const user = verifyData.user;
|
|
||||||
|
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
{
|
{
|
||||||
ok: true,
|
ok: true,
|
||||||
session: session || null,
|
session: data.session || null,
|
||||||
user: user || null,
|
user: data.session?.user || null,
|
||||||
},
|
},
|
||||||
200,
|
200,
|
||||||
corsHeaders,
|
corsHeaders,
|
||||||
|
|
@ -187,4 +103,4 @@ Deno.serve(async (request) => {
|
||||||
corsHeaders,
|
corsHeaders,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
# Custom entrypoint for Kong that builds Lua expressions for request-transformer
|
|
||||||
# and performs environment variable substitution in the declarative config.
|
|
||||||
|
|
||||||
# Build Lua expressions for translating opaque API keys to asymmetric JWTs.
|
|
||||||
# When opaque keys are not configured (empty env vars), expressions fall through
|
|
||||||
# to legacy-only behavior - just passing apikey as-is.
|
|
||||||
#
|
|
||||||
# Full expression logic (when opaque keys are configured):
|
|
||||||
# 1. If Authorization header exists and is NOT an sb_ key -> pass through (user session JWT)
|
|
||||||
# 2. If apikey matches secret key -> set service_role asymmetric JWT internal "API key"
|
|
||||||
# 3. If apikey matches publishable key -> set anon asymmetric JWT internal "API key"
|
|
||||||
# 4. Fallback: pass apikey as-is (legacy HS256 JWT)
|
|
||||||
|
|
||||||
if [ -n "$SUPABASE_SECRET_KEY" ] && [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
|
|
||||||
# Opaque keys configured -> full translation expressions
|
|
||||||
export LUA_AUTH_EXPR="\$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == '$SUPABASE_SECRET_KEY' and 'Bearer $SERVICE_ROLE_KEY_ASYMMETRIC') or (headers.apikey == '$SUPABASE_PUBLISHABLE_KEY' and 'Bearer $ANON_KEY_ASYMMETRIC') or headers.apikey)"
|
|
||||||
|
|
||||||
# Realtime WebSocket: reads from query_params.apikey (supabase-js sends apikey
|
|
||||||
# via query string), outputs to x-api-key header which Realtime checks first.
|
|
||||||
export LUA_RT_WS_EXPR="\$((query_params.apikey == '$SUPABASE_SECRET_KEY' and '$SERVICE_ROLE_KEY_ASYMMETRIC') or (query_params.apikey == '$SUPABASE_PUBLISHABLE_KEY' and '$ANON_KEY_ASYMMETRIC') or query_params.apikey)"
|
|
||||||
else
|
|
||||||
# Legacy API keys, not sb_ API keys -> pass apikey through unchanged
|
|
||||||
export LUA_AUTH_EXPR="\$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or headers.apikey)"
|
|
||||||
export LUA_RT_WS_EXPR="\$(query_params.apikey)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Substitute environment variables in the Kong declarative config.
|
|
||||||
# Uses awk instead of eval/echo to preserve YAML quoting (eval strips double
|
|
||||||
# quotes, breaking "Header: value" patterns that YAML parses as mappings).
|
|
||||||
awk '{
|
|
||||||
result = ""
|
|
||||||
rest = $0
|
|
||||||
while (match(rest, /\$[A-Za-z_][A-Za-z_0-9]*/)) {
|
|
||||||
varname = substr(rest, RSTART + 1, RLENGTH - 1)
|
|
||||||
if (varname in ENVIRON) {
|
|
||||||
result = result substr(rest, 1, RSTART - 1) ENVIRON[varname]
|
|
||||||
} else {
|
|
||||||
result = result substr(rest, 1, RSTART + RLENGTH - 1)
|
|
||||||
}
|
|
||||||
rest = substr(rest, RSTART + RLENGTH)
|
|
||||||
}
|
|
||||||
print result rest
|
|
||||||
}' /home/kong/temp.yml > "$KONG_DECLARATIVE_CONFIG"
|
|
||||||
|
|
||||||
# Remove empty key-auth credentials (unconfigured opaque keys)
|
|
||||||
sed -i '/^[[:space:]]*- key:[[:space:]]*$/d' "$KONG_DECLARATIVE_CONFIG"
|
|
||||||
|
|
||||||
exec /entrypoint.sh kong docker-start
|
|
||||||
|
|
@ -1,411 +0,0 @@
|
||||||
_format_version: '2.1'
|
|
||||||
_transform: true
|
|
||||||
|
|
||||||
###
|
|
||||||
### Consumers / Users
|
|
||||||
###
|
|
||||||
consumers:
|
|
||||||
- username: DASHBOARD
|
|
||||||
- username: anon
|
|
||||||
keyauth_credentials:
|
|
||||||
- key: $SUPABASE_ANON_KEY
|
|
||||||
- key: $SUPABASE_PUBLISHABLE_KEY
|
|
||||||
- username: service_role
|
|
||||||
keyauth_credentials:
|
|
||||||
- key: $SUPABASE_SERVICE_KEY
|
|
||||||
- key: $SUPABASE_SECRET_KEY
|
|
||||||
|
|
||||||
###
|
|
||||||
### Access Control List
|
|
||||||
###
|
|
||||||
acls:
|
|
||||||
- consumer: anon
|
|
||||||
group: anon
|
|
||||||
- consumer: service_role
|
|
||||||
group: admin
|
|
||||||
|
|
||||||
###
|
|
||||||
### Dashboard credentials
|
|
||||||
###
|
|
||||||
basicauth_credentials:
|
|
||||||
- consumer: DASHBOARD
|
|
||||||
username: '$DASHBOARD_USERNAME'
|
|
||||||
password: '$DASHBOARD_PASSWORD'
|
|
||||||
|
|
||||||
###
|
|
||||||
### API Routes
|
|
||||||
###
|
|
||||||
services:
|
|
||||||
## Open Auth routes
|
|
||||||
- name: auth-v1-open
|
|
||||||
_comment: 'Auth: /auth/v1/verify* -> http://auth:9999/verify*'
|
|
||||||
url: http://auth:9999/verify
|
|
||||||
routes:
|
|
||||||
- name: auth-v1-open
|
|
||||||
strip_path: true
|
|
||||||
paths:
|
|
||||||
- /auth/v1/verify
|
|
||||||
plugins:
|
|
||||||
- name: cors
|
|
||||||
- name: auth-v1-open-callback
|
|
||||||
_comment: 'Auth: /auth/v1/callback* -> http://auth:9999/callback*'
|
|
||||||
url: http://auth:9999/callback
|
|
||||||
routes:
|
|
||||||
- name: auth-v1-open-callback
|
|
||||||
strip_path: true
|
|
||||||
paths:
|
|
||||||
- /auth/v1/callback
|
|
||||||
plugins:
|
|
||||||
- name: cors
|
|
||||||
- name: auth-v1-open-authorize
|
|
||||||
_comment: 'Auth: /auth/v1/authorize* -> http://auth:9999/authorize*'
|
|
||||||
url: http://auth:9999/authorize
|
|
||||||
routes:
|
|
||||||
- name: auth-v1-open-authorize
|
|
||||||
strip_path: true
|
|
||||||
paths:
|
|
||||||
- /auth/v1/authorize
|
|
||||||
plugins:
|
|
||||||
- name: cors
|
|
||||||
- name: auth-v1-open-jwks
|
|
||||||
_comment: 'Auth: /auth/v1/.well-known/jwks.json -> http://auth:9999/.well-known/jwks.json'
|
|
||||||
url: http://auth:9999/.well-known/jwks.json
|
|
||||||
routes:
|
|
||||||
- name: auth-v1-open-jwks
|
|
||||||
strip_path: true
|
|
||||||
paths:
|
|
||||||
- /auth/v1/.well-known/jwks.json
|
|
||||||
plugins:
|
|
||||||
- name: cors
|
|
||||||
|
|
||||||
## Secure Auth routes
|
|
||||||
- name: auth-v1
|
|
||||||
_comment: 'Auth: /auth/v1/* -> http://auth:9999/*'
|
|
||||||
url: http://auth:9999/
|
|
||||||
routes:
|
|
||||||
- name: auth-v1-all
|
|
||||||
strip_path: true
|
|
||||||
paths:
|
|
||||||
- /auth/v1/
|
|
||||||
plugins:
|
|
||||||
- name: cors
|
|
||||||
- name: key-auth
|
|
||||||
config:
|
|
||||||
hide_credentials: false
|
|
||||||
- name: request-transformer
|
|
||||||
config:
|
|
||||||
add:
|
|
||||||
headers:
|
|
||||||
- "Authorization: $LUA_AUTH_EXPR"
|
|
||||||
replace:
|
|
||||||
headers:
|
|
||||||
- "Authorization: $LUA_AUTH_EXPR"
|
|
||||||
- name: acl
|
|
||||||
config:
|
|
||||||
hide_groups_header: true
|
|
||||||
allow:
|
|
||||||
- admin
|
|
||||||
- anon
|
|
||||||
|
|
||||||
## Secure PostgREST routes
|
|
||||||
- name: rest-v1
|
|
||||||
_comment: 'PostgREST: /rest/v1/* -> http://rest:3000/*'
|
|
||||||
url: http://rest:3000/
|
|
||||||
routes:
|
|
||||||
- name: rest-v1-all
|
|
||||||
strip_path: true
|
|
||||||
paths:
|
|
||||||
- /rest/v1/
|
|
||||||
plugins:
|
|
||||||
- name: cors
|
|
||||||
- name: key-auth
|
|
||||||
config:
|
|
||||||
hide_credentials: false
|
|
||||||
- name: request-transformer
|
|
||||||
config:
|
|
||||||
add:
|
|
||||||
headers:
|
|
||||||
- "Authorization: $LUA_AUTH_EXPR"
|
|
||||||
replace:
|
|
||||||
headers:
|
|
||||||
- "Authorization: $LUA_AUTH_EXPR"
|
|
||||||
- name: acl
|
|
||||||
config:
|
|
||||||
hide_groups_header: true
|
|
||||||
allow:
|
|
||||||
- admin
|
|
||||||
- anon
|
|
||||||
|
|
||||||
## Secure GraphQL routes
|
|
||||||
- name: graphql-v1
|
|
||||||
_comment: 'PostgREST: /graphql/v1/* -> http://rest:3000/rpc/graphql'
|
|
||||||
url: http://rest:3000/rpc/graphql
|
|
||||||
routes:
|
|
||||||
- name: graphql-v1-all
|
|
||||||
strip_path: true
|
|
||||||
paths:
|
|
||||||
- /graphql/v1
|
|
||||||
plugins:
|
|
||||||
- name: cors
|
|
||||||
- name: key-auth
|
|
||||||
config:
|
|
||||||
hide_credentials: false
|
|
||||||
- name: request-transformer
|
|
||||||
config:
|
|
||||||
add:
|
|
||||||
headers:
|
|
||||||
- "Content-Profile: graphql_public"
|
|
||||||
- "Authorization: $LUA_AUTH_EXPR"
|
|
||||||
replace:
|
|
||||||
headers:
|
|
||||||
- "Authorization: $LUA_AUTH_EXPR"
|
|
||||||
- name: acl
|
|
||||||
config:
|
|
||||||
hide_groups_header: true
|
|
||||||
allow:
|
|
||||||
- admin
|
|
||||||
- anon
|
|
||||||
|
|
||||||
## Secure Realtime routes
|
|
||||||
- name: realtime-v1-ws
|
|
||||||
_comment: 'Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*'
|
|
||||||
url: http://realtime-dev.supabase-realtime:4000/socket
|
|
||||||
protocol: ws
|
|
||||||
routes:
|
|
||||||
- name: realtime-v1-ws
|
|
||||||
strip_path: true
|
|
||||||
paths:
|
|
||||||
- /realtime/v1/
|
|
||||||
plugins:
|
|
||||||
- name: cors
|
|
||||||
- name: key-auth
|
|
||||||
config:
|
|
||||||
hide_credentials: false
|
|
||||||
- name: request-transformer
|
|
||||||
config:
|
|
||||||
add:
|
|
||||||
headers:
|
|
||||||
- "x-api-key:$LUA_RT_WS_EXPR"
|
|
||||||
replace:
|
|
||||||
querystring:
|
|
||||||
- "apikey:$LUA_RT_WS_EXPR"
|
|
||||||
- name: acl
|
|
||||||
config:
|
|
||||||
hide_groups_header: true
|
|
||||||
allow:
|
|
||||||
- admin
|
|
||||||
- anon
|
|
||||||
|
|
||||||
- name: realtime-v1-rest
|
|
||||||
_comment: 'Realtime: /realtime/v1/api/* -> http://realtime:4000/api/*'
|
|
||||||
url: http://realtime-dev.supabase-realtime:4000/api
|
|
||||||
protocol: http
|
|
||||||
routes:
|
|
||||||
- name: realtime-v1-rest
|
|
||||||
strip_path: true
|
|
||||||
paths:
|
|
||||||
- /realtime/v1/api
|
|
||||||
plugins:
|
|
||||||
- name: cors
|
|
||||||
- name: key-auth
|
|
||||||
config:
|
|
||||||
hide_credentials: false
|
|
||||||
- name: request-transformer
|
|
||||||
config:
|
|
||||||
add:
|
|
||||||
headers:
|
|
||||||
- "Authorization: $LUA_AUTH_EXPR"
|
|
||||||
replace:
|
|
||||||
headers:
|
|
||||||
- "Authorization: $LUA_AUTH_EXPR"
|
|
||||||
- name: acl
|
|
||||||
config:
|
|
||||||
hide_groups_header: true
|
|
||||||
allow:
|
|
||||||
- admin
|
|
||||||
- anon
|
|
||||||
|
|
||||||
## Storage API endpoint (with Authorization header transformation).
|
|
||||||
## No key-auth — S3 protocol requests don't carry an apikey header.
|
|
||||||
##
|
|
||||||
## The request-transformer translates opaque API keys to asymmetric JWTs
|
|
||||||
## and passes through existing Authorization headers (user JWTs, AWS SigV4).
|
|
||||||
## When no Authorization or apikey header is present (S3 presigned URLs),
|
|
||||||
## the Lua expression evaluates to nil which Kong renders as empty string.
|
|
||||||
## The post-function strips this empty header so Storage's S3 signature
|
|
||||||
## verification falls through to query-parameter parsing.
|
|
||||||
- name: storage-v1
|
|
||||||
_comment: 'Storage: /storage/v1/* -> http://storage:5000/*'
|
|
||||||
url: http://storage:5000/
|
|
||||||
routes:
|
|
||||||
- name: storage-v1-all
|
|
||||||
strip_path: true
|
|
||||||
paths:
|
|
||||||
- /storage/v1/
|
|
||||||
plugins:
|
|
||||||
- name: cors
|
|
||||||
- name: request-transformer
|
|
||||||
config:
|
|
||||||
add:
|
|
||||||
headers:
|
|
||||||
- "Authorization: $LUA_AUTH_EXPR"
|
|
||||||
replace:
|
|
||||||
headers:
|
|
||||||
- "Authorization: $LUA_AUTH_EXPR"
|
|
||||||
- name: post-function
|
|
||||||
config:
|
|
||||||
access:
|
|
||||||
- |
|
|
||||||
local auth = kong.request.get_header("authorization")
|
|
||||||
if auth == nil or auth == "" or auth:find("^%s*$") then
|
|
||||||
kong.service.request.clear_header("authorization")
|
|
||||||
end
|
|
||||||
|
|
||||||
## Edge Functions routes
|
|
||||||
- name: functions-v1
|
|
||||||
_comment: 'Edge Functions: /functions/v1/* -> http://functions:9000/*'
|
|
||||||
url: http://functions:9000/
|
|
||||||
read_timeout: 150000
|
|
||||||
routes:
|
|
||||||
- name: functions-v1-all
|
|
||||||
strip_path: true
|
|
||||||
paths:
|
|
||||||
- /functions/v1/
|
|
||||||
plugins:
|
|
||||||
- name: cors
|
|
||||||
config:
|
|
||||||
origins:
|
|
||||||
- "https://dost.supersamsev.ru"
|
|
||||||
- "https://supa.supersamsev.ru"
|
|
||||||
- "https://supasevdev.mkn8n.ru"
|
|
||||||
- "http://localhost:5173"
|
|
||||||
- "http://localhost:3000"
|
|
||||||
- "http://localhost:5174"
|
|
||||||
methods:
|
|
||||||
- GET
|
|
||||||
- DELETE
|
|
||||||
- POST
|
|
||||||
- PATCH
|
|
||||||
- OPTIONS
|
|
||||||
- PUT
|
|
||||||
headers:
|
|
||||||
- Content-Type
|
|
||||||
- Authorization
|
|
||||||
- apikey
|
|
||||||
- x-application-name
|
|
||||||
- x-client-info
|
|
||||||
credentials: true
|
|
||||||
max_age: 86400
|
|
||||||
|
|
||||||
## OAuth 2.0 Authorization Server Metadata (RFC 8414)
|
|
||||||
- name: well-known-oauth
|
|
||||||
_comment: 'Auth: /.well-known/oauth-authorization-server -> http://auth:9999/.well-known/oauth-authorization-server'
|
|
||||||
url: http://auth:9999/.well-known/oauth-authorization-server
|
|
||||||
routes:
|
|
||||||
- name: well-known-oauth
|
|
||||||
strip_path: true
|
|
||||||
paths:
|
|
||||||
- /.well-known/oauth-authorization-server
|
|
||||||
plugins:
|
|
||||||
- name: cors
|
|
||||||
|
|
||||||
## Analytics routes
|
|
||||||
## Not used - Studio and Vector talk directly to analytics via Docker networking.
|
|
||||||
## If external access is needed, add routes with key-auth matching Logflare's x-api-key auth.
|
|
||||||
# - name: analytics-v1-api
|
|
||||||
# _comment: 'Analytics: /analytics/v1/api/endpoints/* -> http://logflare:4000/api/endpoints/*'
|
|
||||||
# url: http://analytics:4000/api/endpoints
|
|
||||||
# routes:
|
|
||||||
# - name: analytics-v1-api
|
|
||||||
# strip_path: true
|
|
||||||
# paths:
|
|
||||||
# - /analytics/v1/api/endpoints/
|
|
||||||
# - name: analytics-v1
|
|
||||||
# _comment: 'Analytics: /analytics/v1/* -> http://logflare:4000/*'
|
|
||||||
# url: http://analytics:4000/
|
|
||||||
# routes:
|
|
||||||
# - name: dashboard-v1-all
|
|
||||||
# strip_path: true
|
|
||||||
# paths:
|
|
||||||
# - /analytics/v1
|
|
||||||
# plugins:
|
|
||||||
# - name: cors
|
|
||||||
# - name: basic-auth
|
|
||||||
# config:
|
|
||||||
# hide_credentials: true
|
|
||||||
|
|
||||||
## Secure Database routes
|
|
||||||
- name: meta
|
|
||||||
_comment: 'pg-meta: /pg/* -> http://pg-meta:8080/*'
|
|
||||||
url: http://meta:8080/
|
|
||||||
routes:
|
|
||||||
- name: meta-all
|
|
||||||
strip_path: true
|
|
||||||
paths:
|
|
||||||
- /pg/
|
|
||||||
plugins:
|
|
||||||
- name: key-auth
|
|
||||||
config:
|
|
||||||
hide_credentials: false
|
|
||||||
- name: acl
|
|
||||||
config:
|
|
||||||
hide_groups_header: true
|
|
||||||
allow:
|
|
||||||
- admin
|
|
||||||
|
|
||||||
## Block access to /api/mcp
|
|
||||||
- name: mcp-blocker
|
|
||||||
_comment: 'Block direct access to /api/mcp'
|
|
||||||
url: http://studio:3000/api/mcp
|
|
||||||
routes:
|
|
||||||
- name: mcp-blocker-route
|
|
||||||
strip_path: true
|
|
||||||
paths:
|
|
||||||
- /api/mcp
|
|
||||||
plugins:
|
|
||||||
- name: request-termination
|
|
||||||
config:
|
|
||||||
status_code: 403
|
|
||||||
message: "Access is forbidden."
|
|
||||||
|
|
||||||
## MCP endpoint - local access
|
|
||||||
- name: mcp
|
|
||||||
_comment: 'MCP: /mcp -> http://studio:3000/api/mcp (local access)'
|
|
||||||
url: http://studio:3000/api/mcp
|
|
||||||
routes:
|
|
||||||
- name: mcp
|
|
||||||
strip_path: true
|
|
||||||
paths:
|
|
||||||
- /mcp
|
|
||||||
plugins:
|
|
||||||
# Block access to /mcp by default
|
|
||||||
- name: request-termination
|
|
||||||
config:
|
|
||||||
status_code: 403
|
|
||||||
message: "Access is forbidden."
|
|
||||||
# Enable local access (danger zone!)
|
|
||||||
# 1. Comment out the 'request-termination' section above
|
|
||||||
# 2. Uncomment the entire section below, including 'deny'
|
|
||||||
# 3. Add your local IPs to the 'allow' list
|
|
||||||
#- name: cors
|
|
||||||
#- name: ip-restriction
|
|
||||||
# config:
|
|
||||||
# allow:
|
|
||||||
# - 127.0.0.1
|
|
||||||
# - ::1
|
|
||||||
# deny: []
|
|
||||||
|
|
||||||
## Protected Dashboard - catch all remaining routes
|
|
||||||
- name: dashboard
|
|
||||||
_comment: 'Studio: /* -> http://studio:3000/*'
|
|
||||||
url: http://studio:3000/
|
|
||||||
routes:
|
|
||||||
- name: dashboard-all
|
|
||||||
strip_path: true
|
|
||||||
paths:
|
|
||||||
- /
|
|
||||||
plugins:
|
|
||||||
- name: cors
|
|
||||||
- name: basic-auth
|
|
||||||
config:
|
|
||||||
hide_credentials: true
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
# Edge Functions
|
|
||||||
|
|
||||||
## `chatbot-webhook`
|
|
||||||
|
|
||||||
Принимает webhook от `telegram`, `vk`, `messenger_max`, нормализует сообщение, пишет его в
|
|
||||||
`chat_messages` и при необходимости обновляет статус заказа и `order_history`.
|
|
||||||
|
|
||||||
Требует подпись `X-Signature` или `Authorization: Bearer <INTEGRATION_API_KEY>`, а также
|
|
||||||
ограничивает частоту входящих событий.
|
|
||||||
|
|
||||||
Пример вызова:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST \
|
|
||||||
'https://<project>.supabase.co/functions/v1/chatbot-webhook?provider=telegram' \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{
|
|
||||||
"order_id": "uuid",
|
|
||||||
"text": "Подтверждаю",
|
|
||||||
"action": "confirm_delivery",
|
|
||||||
"external_message_id": "tg-42",
|
|
||||||
"payload": {"slot_id": "slot-1"}
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
## `send-chatbot-message`
|
|
||||||
|
|
||||||
Принимает исходящее сообщение, подготавливает dispatch в нужный канал и логирует отправку в
|
|
||||||
`chat_messages`.
|
|
||||||
|
|
||||||
Если передан `workflowAction=send_delivery_offer`, функция дополнительно переводит заказ в
|
|
||||||
`Ожидает ответа клиента` и выставляет `delivery_agreement_status = 'Отправлено клиенту'`.
|
|
||||||
|
|
||||||
Ожидаемые переменные:
|
|
||||||
|
|
||||||
- `SUPABASE_URL`
|
|
||||||
- `SUPABASE_SERVICE_ROLE_KEY`
|
|
||||||
- `INTEGRATION_API_KEY`
|
|
||||||
- `INTEGRATION_WEBHOOK_SECRET`
|
|
||||||
- `TELEGRAM_BOT_TOKEN`
|
|
||||||
- `VK_BOT_TOKEN`
|
|
||||||
- `MESSENGER_MAX_TOKEN`
|
|
||||||
|
|
||||||
## `request-otp`
|
|
||||||
|
|
||||||
Отправляет код входа по email после проверки лимитов по IP и адресу. Используется страницей
|
|
||||||
логина вместо прямого вызова `supabase.auth.signInWithOtp` из браузера.
|
|
||||||
|
|
||||||
## `verify-otp`
|
|
||||||
|
|
||||||
Проверяет код входа, тоже с rate limit, и возвращает session для установки в клиенте.
|
|
||||||
|
|
||||||
## `create-delivery-invitation`
|
|
||||||
|
|
||||||
Создает или обновляет активное приглашение для публичной клиентской ссылки, сохраняет
|
|
||||||
`delivery_invitations`, обновляет заказ в статус `Ожидает ответа клиента` и возвращает публичный URL.
|
|
||||||
|
|
||||||
Обязательная переменная окружения:
|
|
||||||
|
|
||||||
- `PUBLIC_APP_URL`
|
|
||||||
|
|
||||||
## `get-delivery-invitation`
|
|
||||||
|
|
||||||
Возвращает публичное состояние приглашения по токену. Используется страницей клиента для показа
|
|
||||||
актуального статуса заказа.
|
|
||||||
|
|
||||||
## `confirm-delivery-choice`
|
|
||||||
|
|
||||||
Фиксирует выбор времени доставки клиентом, переводит заказ в `Доставка согласована` и создает
|
|
||||||
историю события.
|
|
||||||
|
|
||||||
## `update-order-group-delivery-choice`
|
|
||||||
|
|
||||||
Фиксирует ручное согласование доставки по группе `order_groups`.
|
|
||||||
Используется менеджером или логистом, когда клиент согласовал дату и половину дня напрямую.
|
|
||||||
|
|
||||||
## `transfer-to-logistics`
|
|
||||||
|
|
||||||
Используется для ручной передачи заказа логисту или перевода в `Платное хранение`.
|
|
||||||
|
|
||||||
## `report-delivery-result`
|
|
||||||
|
|
||||||
Фиксирует итог доставки, включая успешную доставку и проблемные сценарии.
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.8";
|
|
||||||
import { getOrderUpdateForInboundAction } from "./workflow.ts";
|
|
||||||
|
|
||||||
export type ProviderName = "telegram" | "vk" | "messenger_max";
|
|
||||||
|
|
||||||
export type NormalizedChatEvent = {
|
|
||||||
provider: ProviderName;
|
|
||||||
orderId: string;
|
|
||||||
externalMessageId: string | null;
|
|
||||||
senderType: "client" | "bot" | "system";
|
|
||||||
text: string;
|
|
||||||
payload: Record<string, unknown>;
|
|
||||||
action: "confirm_delivery" | "reschedule" | "cancel_delivery" | "unknown";
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createServiceClient = () => {
|
|
||||||
const supabaseUrl = Deno.env.get("SUPABASE_URL") || "";
|
|
||||||
const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") || "";
|
|
||||||
return createClient(supabaseUrl, serviceRoleKey);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Create a Supabase client that respects RLS policies (uses anon key). */
|
|
||||||
export const createAnonClient = () => {
|
|
||||||
const supabaseUrl = Deno.env.get("SUPABASE_URL") || "";
|
|
||||||
const anonKey = Deno.env.get("SUPABASE_ANON_KEY") || "";
|
|
||||||
return createClient(supabaseUrl, anonKey);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const json = (body: unknown, status = 200) =>
|
|
||||||
new Response(JSON.stringify(body), {
|
|
||||||
status,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const normalizeIncomingEvent = (
|
|
||||||
provider: ProviderName,
|
|
||||||
body: Record<string, unknown>,
|
|
||||||
): NormalizedChatEvent => {
|
|
||||||
const payload = (body.payload as Record<string, unknown>) || {};
|
|
||||||
|
|
||||||
return {
|
|
||||||
provider,
|
|
||||||
orderId: String(body.order_id || payload.order_id || ""),
|
|
||||||
externalMessageId: body.external_message_id ? String(body.external_message_id) : null,
|
|
||||||
senderType: "client",
|
|
||||||
text: String(body.text || payload.text || ""),
|
|
||||||
payload,
|
|
||||||
action: resolveAction(body.action || payload.action),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const resolveAction = (action: unknown): NormalizedChatEvent["action"] => {
|
|
||||||
switch (String(action || "").toLowerCase()) {
|
|
||||||
case "confirm":
|
|
||||||
case "confirm_delivery":
|
|
||||||
return "confirm_delivery";
|
|
||||||
case "reschedule":
|
|
||||||
return "reschedule";
|
|
||||||
case "cancel":
|
|
||||||
case "cancel_delivery":
|
|
||||||
return "cancel_delivery";
|
|
||||||
default:
|
|
||||||
return "unknown";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const orderUpdateByAction = (action: NormalizedChatEvent["action"]) =>
|
|
||||||
getOrderUpdateForInboundAction(action);
|
|
||||||
|
|
||||||
export const channelFromProvider = (provider: ProviderName) => provider;
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
DEFAULT_AVAILABLE_SLOTS,
|
|
||||||
buildPublicInvitationView,
|
|
||||||
getClientInvitationStateFromOrderStatus,
|
|
||||||
getOrderUpdateForDeliveryInvitationAction,
|
|
||||||
isInvitationExpired,
|
|
||||||
normalizeAvailableSlots,
|
|
||||||
} from "./delivery-invitations";
|
|
||||||
|
|
||||||
describe("delivery invitation helpers", () => {
|
|
||||||
it("maps invitation creation to awaiting customer response", () => {
|
|
||||||
expect(getOrderUpdateForDeliveryInvitationAction("create_delivery_invitation")).toEqual({
|
|
||||||
status: "Ожидает ответа клиента",
|
|
||||||
deliveryAgreementStatus: "Отправлено клиенту",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps manual logistics transfer to the logistics handoff status", () => {
|
|
||||||
expect(getOrderUpdateForDeliveryInvitationAction("transfer_to_logistics")).toEqual({
|
|
||||||
status: "Передан логисту",
|
|
||||||
deliveryAgreementStatus: "Нет ответа",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("derives public client state from the current order status", () => {
|
|
||||||
expect(getClientInvitationStateFromOrderStatus("Ожидает ответа клиента")).toBe("awaiting_choice");
|
|
||||||
expect(getClientInvitationStateFromOrderStatus("Передан логисту")).toBe("transferred_to_logistics");
|
|
||||||
expect(getClientInvitationStateFromOrderStatus("Платное хранение")).toBe("paid_storage");
|
|
||||||
expect(getClientInvitationStateFromOrderStatus("Доставлен")).toBe("delivered");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes delivery slots and falls back to the default list", () => {
|
|
||||||
expect(normalizeAvailableSlots([" Утро ", "", "Вечер", "Утро"])).toEqual(["Утро", "Вечер"]);
|
|
||||||
expect(normalizeAvailableSlots([])).toEqual(DEFAULT_AVAILABLE_SLOTS);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("marks expired and revoked invitations as inactive", () => {
|
|
||||||
expect(
|
|
||||||
isInvitationExpired({
|
|
||||||
order_id: "order-1",
|
|
||||||
token_hash: "token",
|
|
||||||
state: "awaiting_choice",
|
|
||||||
expires_at: "2026-04-01T00:00:00.000Z",
|
|
||||||
}, new Date("2026-04-02T00:00:00.000Z")),
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
isInvitationExpired({
|
|
||||||
order_id: "order-1",
|
|
||||||
token_hash: "token",
|
|
||||||
state: "awaiting_choice",
|
|
||||||
revoked_at: "2026-04-01T00:00:00.000Z",
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("masks customer contact details in the public invitation view", () => {
|
|
||||||
const invitation = buildPublicInvitationView(
|
|
||||||
{
|
|
||||||
order_id: "order-1",
|
|
||||||
token_hash: "token",
|
|
||||||
state: "awaiting_choice",
|
|
||||||
customer_name: "Мария Волкова",
|
|
||||||
customer_phone: "+7 978 123-45-67",
|
|
||||||
order_number: "CD-240031",
|
|
||||||
available_slots: ["2026-04-15, До обеда"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order_number: "CD-240031",
|
|
||||||
customer: {
|
|
||||||
name: "Мария Волкова",
|
|
||||||
phone: "+7 978 123-45-67",
|
|
||||||
items: [{ name: "Кухонный гарнитур", quantity: "1 комплект" }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(invitation.customerName).toBe("Мария В.");
|
|
||||||
expect(invitation.customerPhone).toContain("***");
|
|
||||||
expect(invitation.orderStatus).toBeNull();
|
|
||||||
expect(invitation.deliveryAgreementStatus).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,313 +0,0 @@
|
||||||
import {
|
|
||||||
maskCustomerName,
|
|
||||||
maskPhoneNumber,
|
|
||||||
} from "./security.ts";
|
|
||||||
|
|
||||||
export type DeliveryInvitationAction =
|
|
||||||
| "create_delivery_invitation"
|
|
||||||
| "send_delivery_offer"
|
|
||||||
| "send_delivery_reminder"
|
|
||||||
| "request_new_link"
|
|
||||||
| "confirm_delivery_choice"
|
|
||||||
| "transfer_to_logistics"
|
|
||||||
| "mark_paid_storage"
|
|
||||||
| "mark_delivered";
|
|
||||||
|
|
||||||
export type DeliveryInvitationPublicState =
|
|
||||||
| "awaiting_choice"
|
|
||||||
| "opened"
|
|
||||||
| "reminder_sent"
|
|
||||||
| "transferred_to_logistics"
|
|
||||||
| "paid_storage"
|
|
||||||
| "delivered"
|
|
||||||
| "agreed"
|
|
||||||
| "default";
|
|
||||||
|
|
||||||
export const DEFAULT_AVAILABLE_SLOTS = ["Первая половина дня", "Вторая половина дня"];
|
|
||||||
|
|
||||||
export const getOrderUpdateForDeliveryInvitationAction = (action: DeliveryInvitationAction) => {
|
|
||||||
switch (action) {
|
|
||||||
case "create_delivery_invitation":
|
|
||||||
case "send_delivery_offer":
|
|
||||||
case "send_delivery_reminder":
|
|
||||||
case "request_new_link":
|
|
||||||
return {
|
|
||||||
status: "Ожидает ответа клиента",
|
|
||||||
deliveryAgreementStatus: "Отправлено клиенту",
|
|
||||||
};
|
|
||||||
case "confirm_delivery_choice":
|
|
||||||
return {
|
|
||||||
status: "Доставка согласована",
|
|
||||||
deliveryAgreementStatus: "Подтверждено клиентом",
|
|
||||||
};
|
|
||||||
case "transfer_to_logistics":
|
|
||||||
return {
|
|
||||||
status: "Передан логисту",
|
|
||||||
deliveryAgreementStatus: "Нет ответа",
|
|
||||||
};
|
|
||||||
case "mark_paid_storage":
|
|
||||||
return {
|
|
||||||
status: "Платное хранение",
|
|
||||||
deliveryAgreementStatus: "Нет ответа",
|
|
||||||
};
|
|
||||||
case "mark_delivered":
|
|
||||||
return {
|
|
||||||
status: "Доставлен",
|
|
||||||
deliveryAgreementStatus: "Подтверждено клиентом",
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getClientInvitationStateFromOrderStatus = (
|
|
||||||
status: string,
|
|
||||||
): DeliveryInvitationPublicState => {
|
|
||||||
switch (status) {
|
|
||||||
case "Ожидает ответа клиента":
|
|
||||||
return "awaiting_choice";
|
|
||||||
case "Ожидает согласования доставки":
|
|
||||||
return "opened";
|
|
||||||
case "Напоминание отправлено":
|
|
||||||
case "Переход отправлен":
|
|
||||||
return "reminder_sent";
|
|
||||||
case "Передан логисту":
|
|
||||||
return "transferred_to_logistics";
|
|
||||||
case "Платное хранение":
|
|
||||||
return "paid_storage";
|
|
||||||
case "Доставлен":
|
|
||||||
return "delivered";
|
|
||||||
case "Доставка согласована":
|
|
||||||
return "agreed";
|
|
||||||
default:
|
|
||||||
return "default";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getClientInvitationStateFromOrderGroupStatus = (
|
|
||||||
deliveryStatus: string | null | undefined,
|
|
||||||
invitationState: string | null | undefined,
|
|
||||||
): DeliveryInvitationPublicState => {
|
|
||||||
if (deliveryStatus === "agreed") {
|
|
||||||
return "agreed";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deliveryStatus === "delivered") {
|
|
||||||
return "delivered";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (["awaiting_choice", "opened", "reminder_sent"].includes(String(invitationState || ""))) {
|
|
||||||
return invitationState as DeliveryInvitationPublicState;
|
|
||||||
}
|
|
||||||
|
|
||||||
return "default";
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isActiveInvitationState = (state: DeliveryInvitationPublicState) =>
|
|
||||||
state === "awaiting_choice" || state === "opened" || state === "reminder_sent";
|
|
||||||
|
|
||||||
export const generateInvitationToken = () => crypto.randomUUID().replaceAll("-", "");
|
|
||||||
|
|
||||||
export const hashInvitationToken = async (token: string) => {
|
|
||||||
const bytes = new TextEncoder().encode(token);
|
|
||||||
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
||||||
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
||||||
};
|
|
||||||
|
|
||||||
export const normalizeAvailableSlots = (availableSlots?: string[] | null) => {
|
|
||||||
const slots = availableSlots?.map((slot) => slot.trim()).filter(Boolean) || [];
|
|
||||||
return slots.length > 0 ? Array.from(new Set(slots)) : [...DEFAULT_AVAILABLE_SLOTS];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const buildDefaultDatedAvailableSlots = (now = new Date()) => {
|
|
||||||
const formatIsoDate = (date: Date) => date.toISOString().slice(0, 10);
|
|
||||||
const addDays = (date: Date, days: number) => {
|
|
||||||
const next = new Date(date);
|
|
||||||
next.setUTCDate(next.getUTCDate() + days);
|
|
||||||
return next;
|
|
||||||
};
|
|
||||||
|
|
||||||
const firstDay = formatIsoDate(addDays(now, 1));
|
|
||||||
const secondDay = formatIsoDate(addDays(now, 2));
|
|
||||||
|
|
||||||
return [
|
|
||||||
`${firstDay}, Первая половина дня`,
|
|
||||||
`${firstDay}, Вторая половина дня`,
|
|
||||||
`${secondDay}, Первая половина дня`,
|
|
||||||
`${secondDay}, Вторая половина дня`,
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const resolvePublicAppUrl = (
|
|
||||||
request: Request,
|
|
||||||
fallbackEnv?: string,
|
|
||||||
) => {
|
|
||||||
const origin = request.headers.get("origin") || request.headers.get("referer") || "";
|
|
||||||
const envValue =
|
|
||||||
fallbackEnv ||
|
|
||||||
(typeof Deno !== "undefined" ? Deno.env.get("PUBLIC_APP_URL") || Deno.env.get("APP_PUBLIC_URL") : "");
|
|
||||||
return (envValue || origin || "").replace(/\/$/, "");
|
|
||||||
};
|
|
||||||
|
|
||||||
export const buildInvitationUrl = (baseUrl: string, token: string) =>
|
|
||||||
`${baseUrl.replace(/\/$/, "")}/delivery/${token}`;
|
|
||||||
|
|
||||||
export type DeliveryInvitationRecord = {
|
|
||||||
id?: string;
|
|
||||||
order_id?: string | null;
|
|
||||||
order_group_id?: string | null;
|
|
||||||
token_hash: string;
|
|
||||||
state: string;
|
|
||||||
order_number?: string | null;
|
|
||||||
customer_name?: string | null;
|
|
||||||
customer_phone?: string | null;
|
|
||||||
customer_messenger?: string | null;
|
|
||||||
available_slots?: string[] | null;
|
|
||||||
expires_at?: string | null;
|
|
||||||
revoked_at?: string | null;
|
|
||||||
delivery_date?: string | null;
|
|
||||||
delivery_time?: string | null;
|
|
||||||
sent_at?: string | null;
|
|
||||||
opened_at?: string | null;
|
|
||||||
confirmed_at?: string | null;
|
|
||||||
logistics_transferred_at?: string | null;
|
|
||||||
paid_storage_at?: string | null;
|
|
||||||
delivered_at?: string | null;
|
|
||||||
updated_at?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OrderGroupInvitationSource = {
|
|
||||||
id: string;
|
|
||||||
group_key?: string | null;
|
|
||||||
customer?: {
|
|
||||||
name?: string | null;
|
|
||||||
phone?: string | null;
|
|
||||||
date?: string | null;
|
|
||||||
} | null;
|
|
||||||
customer_name?: string | null;
|
|
||||||
customer_phone?: string | null;
|
|
||||||
customer_date?: string | null;
|
|
||||||
order_numbers?: string[] | null;
|
|
||||||
delivery_status?: string | null;
|
|
||||||
delivery_link?: string | null;
|
|
||||||
source_orders?: unknown[] | null;
|
|
||||||
};
|
|
||||||
export const isInvitationExpired = (invitation: DeliveryInvitationRecord, now = new Date()) => {
|
|
||||||
if (invitation.revoked_at) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!invitation.expires_at) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Date(invitation.expires_at).getTime() <= now.getTime();
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseGroupKey = (groupKey?: string | null) => {
|
|
||||||
const [phone = "", date = ""] = String(groupKey || "").split("|");
|
|
||||||
return {
|
|
||||||
phone: phone.trim(),
|
|
||||||
date: date.trim(),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const extractOrderItemsFromSourceOrders = (sourceOrders: unknown): Array<{ name: string; quantity: string; items?: unknown[] }> => {
|
|
||||||
if (!Array.isArray(sourceOrders) || sourceOrders.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const items: Array<{ name: string; quantity: string; items?: unknown[] }> = [];
|
|
||||||
|
|
||||||
for (const source of sourceOrders) {
|
|
||||||
if (!source || typeof source !== "object") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const record = source as Record<string, unknown>;
|
|
||||||
const nom = typeof record.nom === "string" ? record.nom : typeof record.name === "string" ? record.name : "";
|
|
||||||
const orderList = Array.isArray(record.orderList) ? record.orderList : Array.isArray(record.items) ? record.items : [];
|
|
||||||
|
|
||||||
if (orderList.length > 0) {
|
|
||||||
items.push({
|
|
||||||
name: nom || "Позиция",
|
|
||||||
quantity: "",
|
|
||||||
items: orderList.map((item: unknown) => {
|
|
||||||
if (!item || typeof item !== "object") {
|
|
||||||
return { name: String(item), quantity: "" };
|
|
||||||
}
|
|
||||||
const row = item as Record<string, unknown>;
|
|
||||||
return {
|
|
||||||
name: String(row.product_name || row.name || row.title || ""),
|
|
||||||
quantity: String(row.product_quantity || row.quantity || row.count || row.amount || ""),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
} else if (nom) {
|
|
||||||
items.push({ name: nom, quantity: "" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const buildPublicOrderGroupInvitationView = (
|
|
||||||
invitation: DeliveryInvitationRecord,
|
|
||||||
group: OrderGroupInvitationSource,
|
|
||||||
) => {
|
|
||||||
const parsedKey = parseGroupKey(group.group_key);
|
|
||||||
const customerName = group.customer_name || group.customer?.name || invitation.customer_name || null;
|
|
||||||
const customerPhone = group.customer_phone || group.customer?.phone || invitation.customer_phone || parsedKey.phone || null;
|
|
||||||
const orderNumbers = Array.isArray(group.order_numbers) ? group.order_numbers : [];
|
|
||||||
|
|
||||||
const orderItemsFromSource = extractOrderItemsFromSourceOrders(group.source_orders);
|
|
||||||
const orderItems = orderItemsFromSource.length > 0
|
|
||||||
? orderItemsFromSource
|
|
||||||
: orderNumbers.map((number) => ({ name: number, quantity: "" }));
|
|
||||||
|
|
||||||
return {
|
|
||||||
orderId: invitation.order_group_id || group.id,
|
|
||||||
orderGroupId: invitation.order_group_id || group.id,
|
|
||||||
state: invitation.state,
|
|
||||||
token: "",
|
|
||||||
orderNumber: invitation.order_number || orderNumbers[0] || group.group_key || null,
|
|
||||||
customerName: maskCustomerName(customerName),
|
|
||||||
customerPhone: maskPhoneNumber(customerPhone),
|
|
||||||
orderItems,
|
|
||||||
availableSlots: invitation.available_slots || [],
|
|
||||||
deliveryDate: invitation.delivery_date || null,
|
|
||||||
deliveryTime: invitation.delivery_time || null,
|
|
||||||
orderStatus: null,
|
|
||||||
deliveryAgreementStatus: null,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const buildPublicInvitationView = (
|
|
||||||
invitation: DeliveryInvitationRecord,
|
|
||||||
order: {
|
|
||||||
order_number?: string | null;
|
|
||||||
customer?: { name?: string | null; phone?: string | null; items?: unknown };
|
|
||||||
status?: string | null;
|
|
||||||
delivery_agreement_status?: string | null;
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
const availableSlots = invitation.available_slots || [];
|
|
||||||
const orderItems = Array.isArray(order.customer?.items)
|
|
||||||
? order.customer?.items
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return {
|
|
||||||
orderId: invitation.order_id,
|
|
||||||
state: invitation.state,
|
|
||||||
token: "",
|
|
||||||
orderNumber: order.order_number || invitation.order_number || null,
|
|
||||||
customerName: maskCustomerName(order.customer?.name || invitation.customer_name || null),
|
|
||||||
customerPhone: maskPhoneNumber(order.customer?.phone || invitation.customer_phone || null),
|
|
||||||
orderItems,
|
|
||||||
availableSlots,
|
|
||||||
deliveryDate: invitation.delivery_date || null,
|
|
||||||
deliveryTime: invitation.delivery_time || null,
|
|
||||||
orderStatus: null,
|
|
||||||
deliveryAgreementStatus: null,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
type IntegrationEventPayload = {
|
|
||||||
order_id?: string | null;
|
|
||||||
event_type: string;
|
|
||||||
direction?: "inbound" | "outbound" | "internal";
|
|
||||||
source?: string;
|
|
||||||
status?: string;
|
|
||||||
payload?: Record<string, unknown>;
|
|
||||||
error_message?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const insertIntegrationEvent = async (
|
|
||||||
supabase: {
|
|
||||||
from: (table: string) => {
|
|
||||||
insert: (payload: IntegrationEventPayload) => PromiseLike<{ error: Error | null }>;
|
|
||||||
};
|
|
||||||
},
|
|
||||||
payload: IntegrationEventPayload,
|
|
||||||
) => {
|
|
||||||
const { error } = await supabase.from("integration_events").insert({
|
|
||||||
direction: "internal",
|
|
||||||
source: "supabase-function",
|
|
||||||
status: "success",
|
|
||||||
payload: {},
|
|
||||||
...payload,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,172 +0,0 @@
|
||||||
import { createClient } from 'npm:@supabase/supabase-js@2';
|
|
||||||
|
|
||||||
const ALLOWED_ORIGINS = [
|
|
||||||
'https://supa.supersamsev.ru',
|
|
||||||
'https://dost.supersamsev.ru',
|
|
||||||
'http://localhost:5173',
|
|
||||||
'http://localhost:5174',
|
|
||||||
'http://localhost:3000',
|
|
||||||
'https://supasevdev.mkn8n.ru',
|
|
||||||
];
|
|
||||||
|
|
||||||
export function createServiceClient() {
|
|
||||||
const supabaseUrl = Deno.env.get('SUPABASE_URL') || '';
|
|
||||||
const serviceRoleKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') || '';
|
|
||||||
return createClient(supabaseUrl, serviceRoleKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getClientIp(request: Request): string {
|
|
||||||
const xff = request.headers.get('x-forwarded-for');
|
|
||||||
if (xff) return xff.split(',')[0].trim();
|
|
||||||
return request.headers.get('x-real-ip') || 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCorsHeaders(request: Request, _access: 'public' | 'private') {
|
|
||||||
const origin = request.headers.get('origin') || '';
|
|
||||||
if (!origin) {
|
|
||||||
return {
|
|
||||||
'Access-Control-Allow-Origin': ALLOWED_ORIGINS[0],
|
|
||||||
'Access-Control-Allow-Methods': 'GET,POST,PATCH,DELETE,OPTIONS',
|
|
||||||
'Access-Control-Allow-Headers': 'Content-Type,Authorization,apikey,x-application-name,x-client-info',
|
|
||||||
'Access-Control-Max-Age': '86400',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const allowed = ALLOWED_ORIGINS.some((o) => origin.startsWith(o));
|
|
||||||
if (!allowed) return null;
|
|
||||||
return {
|
|
||||||
'Access-Control-Allow-Origin': origin,
|
|
||||||
'Access-Control-Allow-Methods': 'GET,POST,PATCH,DELETE,OPTIONS',
|
|
||||||
'Access-Control-Allow-Headers': 'Content-Type,Authorization,apikey,x-application-name,x-client-info',
|
|
||||||
'Access-Control-Max-Age': '86400',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function preflightResponse(request: Request, access: 'public' | 'private') {
|
|
||||||
const corsHeaders = getCorsHeaders(request, access);
|
|
||||||
if (!corsHeaders) {
|
|
||||||
return new Response('Origin not allowed', { status: 403 });
|
|
||||||
}
|
|
||||||
return new Response(null, { status: 204, headers: corsHeaders });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function jsonResponse(body: unknown, status = 200, corsHeaders?: Record<string, string>) {
|
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
||||||
if (corsHeaders) Object.assign(headers, corsHeaders);
|
|
||||||
return new Response(JSON.stringify(body), { status, headers });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function hashText(text: string): Promise<string> {
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
const data = encoder.encode(text);
|
|
||||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
||||||
return Array.from(new Uint8Array(hashBuffer))
|
|
||||||
.map((b) => b.toString(16).padStart(2, '0'))
|
|
||||||
.join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
interface JsonBodyResult<T> {
|
|
||||||
body: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readJsonBody<T>(request: Request, options?: { maxBytes?: number }): Promise<JsonBodyResult<T>> {
|
|
||||||
const maxBytes = options?.maxBytes ?? 1024 * 1024;
|
|
||||||
const reader = request.body?.getReader();
|
|
||||||
if (!reader) throw new Error('No body');
|
|
||||||
const chunks: Uint8Array[] = [];
|
|
||||||
let totalBytes = 0;
|
|
||||||
for (;;) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
totalBytes += value.length;
|
|
||||||
if (totalBytes > maxBytes) {
|
|
||||||
reader.cancel();
|
|
||||||
throw Object.assign(new Error('Request body too large'), { status: 413 });
|
|
||||||
}
|
|
||||||
chunks.push(value);
|
|
||||||
}
|
|
||||||
const combined = new Uint8Array(totalBytes);
|
|
||||||
let offset = 0;
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
combined.set(chunk, offset);
|
|
||||||
offset += chunk.length;
|
|
||||||
}
|
|
||||||
const text = new TextDecoder().decode(combined);
|
|
||||||
const body = JSON.parse(text) as T;
|
|
||||||
return { body };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RateLimitOptions {
|
|
||||||
scope: string;
|
|
||||||
key: string;
|
|
||||||
maxCount: number;
|
|
||||||
windowSeconds: number;
|
|
||||||
blockSeconds: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
class RateLimitError extends Error {
|
|
||||||
status: number;
|
|
||||||
constructor(message: string, status: number) {
|
|
||||||
super(message);
|
|
||||||
this.status = status;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function requireRateLimit(supabase: ReturnType<typeof createClient>, options: RateLimitOptions) {
|
|
||||||
const { scope, key, maxCount, windowSeconds, blockSeconds } = options;
|
|
||||||
const tableName = 'rate_limits';
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
const { data: blocked } = await supabase
|
|
||||||
.from(tableName)
|
|
||||||
.select('blocked_until')
|
|
||||||
.eq('scope', scope)
|
|
||||||
.eq('rate_key', key)
|
|
||||||
.gt('blocked_until', now.toISOString())
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (blocked && blocked.length > 0) {
|
|
||||||
throw new RateLimitError('Too many requests. Please try again later.', 429);
|
|
||||||
}
|
|
||||||
|
|
||||||
const windowStart = new Date(now.getTime() - windowSeconds * 1000);
|
|
||||||
const { data: recent, error } = await supabase
|
|
||||||
.from(tableName)
|
|
||||||
.select('id, count')
|
|
||||||
.eq('scope', scope)
|
|
||||||
.eq('rate_key', key)
|
|
||||||
.gte('window_start', windowStart.toISOString());
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Rate limit check error:', error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalCount = recent?.reduce((sum: number, r: { count: number }) => sum + r.count, 0) ?? 0;
|
|
||||||
|
|
||||||
if (totalCount >= maxCount) {
|
|
||||||
const blockedUntil = new Date(now.getTime() + blockSeconds * 1000);
|
|
||||||
await supabase
|
|
||||||
.from(tableName)
|
|
||||||
.update({ blocked_until: blockedUntil.toISOString() })
|
|
||||||
.eq('scope', scope)
|
|
||||||
.eq('rate_key', key)
|
|
||||||
.gte('window_start', windowStart.toISOString());
|
|
||||||
throw new RateLimitError('Too many requests. Please try again later.', 429);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingRow = recent?.[0];
|
|
||||||
if (existingRow) {
|
|
||||||
await supabase
|
|
||||||
.from(tableName)
|
|
||||||
.update({ count: (existingRow as { count: number }).count + 1 })
|
|
||||||
.eq('id', (existingRow as { id: string }).id);
|
|
||||||
} else {
|
|
||||||
await supabase.from(tableName).insert({
|
|
||||||
scope,
|
|
||||||
rate_key: key,
|
|
||||||
window_start: now.toISOString(),
|
|
||||||
count: 1,
|
|
||||||
blocked_until: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
getOrderUpdateForInboundAction,
|
|
||||||
getOrderUpdateForOutboundDispatch,
|
|
||||||
} from "./workflow";
|
|
||||||
|
|
||||||
describe("chatbot workflow mapping", () => {
|
|
||||||
it("maps confirm delivery to agreed delivery statuses", () => {
|
|
||||||
expect(getOrderUpdateForInboundAction("confirm_delivery")).toEqual({
|
|
||||||
status: "Доставка согласована",
|
|
||||||
deliveryAgreementStatus: "Подтверждено клиентом",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps reschedule request to waiting coordination statuses", () => {
|
|
||||||
expect(getOrderUpdateForInboundAction("reschedule")).toEqual({
|
|
||||||
status: "Ожидает согласования доставки",
|
|
||||||
deliveryAgreementStatus: "Перенос запрошен",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("marks outbound delivery offer as awaiting client response", () => {
|
|
||||||
expect(getOrderUpdateForOutboundDispatch("send_delivery_offer")).toEqual({
|
|
||||||
status: "Ожидает ответа клиента",
|
|
||||||
deliveryAgreementStatus: "Отправлено клиенту",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps reminder dispatch in the same awaiting response state", () => {
|
|
||||||
expect(getOrderUpdateForOutboundDispatch("send_delivery_reminder")).toEqual({
|
|
||||||
status: "Ожидает ответа клиента",
|
|
||||||
deliveryAgreementStatus: "Отправлено клиенту",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
import { getOrderUpdateForDeliveryInvitationAction } from "./delivery-invitations.ts";
|
|
||||||
|
|
||||||
export type InboundWorkflowAction =
|
|
||||||
| "confirm_delivery"
|
|
||||||
| "reschedule"
|
|
||||||
| "cancel_delivery"
|
|
||||||
| "unknown";
|
|
||||||
|
|
||||||
export type OutboundWorkflowAction =
|
|
||||||
| "send_delivery_offer"
|
|
||||||
| "send_delivery_reminder"
|
|
||||||
| "custom_message";
|
|
||||||
|
|
||||||
export const getOrderUpdateForInboundAction = (action: InboundWorkflowAction) => {
|
|
||||||
switch (action) {
|
|
||||||
case "confirm_delivery":
|
|
||||||
return {
|
|
||||||
status: "Доставка согласована",
|
|
||||||
deliveryAgreementStatus: "Подтверждено клиентом",
|
|
||||||
};
|
|
||||||
case "reschedule":
|
|
||||||
return {
|
|
||||||
status: "Ожидает согласования доставки",
|
|
||||||
deliveryAgreementStatus: "Перенос запрошен",
|
|
||||||
};
|
|
||||||
case "cancel_delivery":
|
|
||||||
return {
|
|
||||||
status: "Проблема доставки",
|
|
||||||
deliveryAgreementStatus: "Нет ответа",
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getOrderUpdateForOutboundDispatch = (action: OutboundWorkflowAction) => {
|
|
||||||
switch (action) {
|
|
||||||
case "send_delivery_offer":
|
|
||||||
case "send_delivery_reminder":
|
|
||||||
return getOrderUpdateForDeliveryInvitationAction(action);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
import {
|
|
||||||
channelFromProvider,
|
|
||||||
createServiceClient,
|
|
||||||
json,
|
|
||||||
normalizeIncomingEvent,
|
|
||||||
orderUpdateByAction,
|
|
||||||
type ProviderName,
|
|
||||||
} from "../_shared/chatbot.ts";
|
|
||||||
import {
|
|
||||||
getClientIp,
|
|
||||||
getCorsHeaders,
|
|
||||||
hashText,
|
|
||||||
readJsonBody,
|
|
||||||
requireRateLimit,
|
|
||||||
verifyInternalRequest,
|
|
||||||
} from "../_shared/security.ts";
|
|
||||||
|
|
||||||
const MAX_BODY_BYTES = 64 * 1024;
|
|
||||||
|
|
||||||
const allowedProviders = new Set<ProviderName>(["telegram", "vk", "messenger_max"]);
|
|
||||||
|
|
||||||
Deno.serve(async (request) => {
|
|
||||||
if (request.method === "OPTIONS") {
|
|
||||||
const corsHeaders = getCorsHeaders(request, "webhook");
|
|
||||||
return corsHeaders ? new Response("ok", { headers: corsHeaders }) : json({ error: "Origin not allowed" }, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.method !== "POST") {
|
|
||||||
return json({ error: "Method not allowed" }, 405);
|
|
||||||
}
|
|
||||||
|
|
||||||
const corsHeaders = getCorsHeaders(request, "webhook") || {};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const provider = url.searchParams.get("provider") as ProviderName | null;
|
|
||||||
if (!provider || !allowedProviders.has(provider)) {
|
|
||||||
return json({ error: "provider is required" }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { body, rawBody } = await readJsonBody<Record<string, unknown>>(request, {
|
|
||||||
maxBytes: MAX_BODY_BYTES,
|
|
||||||
});
|
|
||||||
await verifyInternalRequest(request, rawBody, {
|
|
||||||
rawBody,
|
|
||||||
secretEnvNames: [
|
|
||||||
`CHATBOT_WEBHOOK_SECRET_${provider.toUpperCase()}`,
|
|
||||||
"CHATBOT_WEBHOOK_SECRET",
|
|
||||||
],
|
|
||||||
tokenEnvNames: [
|
|
||||||
`CHATBOT_WEBHOOK_TOKEN_${provider.toUpperCase()}`,
|
|
||||||
"CHATBOT_WEBHOOK_TOKEN",
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const event = normalizeIncomingEvent(provider, body);
|
|
||||||
if (!event.orderId) {
|
|
||||||
return json({ error: "order_id is required" }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabase = createServiceClient();
|
|
||||||
const rateKey = event.externalMessageId || (await hashText(`${provider}:${getClientIp(request)}:${event.text}`));
|
|
||||||
|
|
||||||
await requireRateLimit(supabase, {
|
|
||||||
scope: `webhook-${provider}`,
|
|
||||||
key: rateKey,
|
|
||||||
maxCount: 60,
|
|
||||||
windowSeconds: 60,
|
|
||||||
blockSeconds: 300,
|
|
||||||
});
|
|
||||||
|
|
||||||
const orderUpdate = orderUpdateByAction(event.action);
|
|
||||||
|
|
||||||
const messagePayload = {
|
|
||||||
order_id: event.orderId,
|
|
||||||
sender_name: "chatbot-webhook",
|
|
||||||
sender_type: event.senderType,
|
|
||||||
channel: channelFromProvider(event.provider),
|
|
||||||
text: event.text || `Inbound ${event.provider} event`,
|
|
||||||
external_message_id: event.externalMessageId,
|
|
||||||
payload: event.payload,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { error: messageError } = await supabase.from("chat_messages").insert(messagePayload);
|
|
||||||
if (messageError && messageError.code !== "23505") {
|
|
||||||
throw messageError;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (orderUpdate) {
|
|
||||||
const { data: currentOrder, error: orderError } = await supabase
|
|
||||||
.from("orders")
|
|
||||||
.select("id, status, delivery_agreement_status")
|
|
||||||
.eq("id", event.orderId)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (orderError) {
|
|
||||||
throw orderError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error: updateError } = await supabase
|
|
||||||
.from("orders")
|
|
||||||
.update({
|
|
||||||
status: orderUpdate.status,
|
|
||||||
delivery_agreement_status: orderUpdate.deliveryAgreementStatus,
|
|
||||||
})
|
|
||||||
.eq("id", event.orderId);
|
|
||||||
|
|
||||||
if (updateError) {
|
|
||||||
throw updateError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error: historyError } = await supabase.from("order_history").insert({
|
|
||||||
order_id: event.orderId,
|
|
||||||
action: `Webhook ${provider}: ${event.action}`,
|
|
||||||
old_status: currentOrder.status,
|
|
||||||
new_status: orderUpdate.status,
|
|
||||||
metadata: {
|
|
||||||
...event.payload,
|
|
||||||
old_delivery_agreement_status: currentOrder.delivery_agreement_status,
|
|
||||||
new_delivery_agreement_status: orderUpdate.deliveryAgreementStatus,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (historyError) {
|
|
||||||
throw historyError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(JSON.stringify({ ok: true }), {
|
|
||||||
headers: corsHeaders,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
return json(
|
|
||||||
{
|
|
||||||
ok: false,
|
|
||||||
error: error instanceof Error ? error.message : "Unexpected error",
|
|
||||||
},
|
|
||||||
500,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,360 +0,0 @@
|
||||||
import {
|
|
||||||
getOrderUpdateForDeliveryInvitationAction,
|
|
||||||
hashInvitationToken,
|
|
||||||
isActiveInvitationState,
|
|
||||||
isInvitationExpired,
|
|
||||||
} from "../_shared/delivery-invitations.ts";
|
|
||||||
import { isValidUuid, requireUuid } from "../_shared/security.ts";
|
|
||||||
import { createServiceClient } from "../_shared/chatbot.ts";
|
|
||||||
import { insertIntegrationEvent } from "../_shared/integration-events.ts";
|
|
||||||
import {
|
|
||||||
getClientIp,
|
|
||||||
getCorsHeaders,
|
|
||||||
hashText,
|
|
||||||
jsonResponse,
|
|
||||||
preflightResponse,
|
|
||||||
readJsonBody,
|
|
||||||
requireRateLimit,
|
|
||||||
requireSameOrigin,
|
|
||||||
} from "../_shared/security.ts";
|
|
||||||
|
|
||||||
const MAX_BODY_BYTES = 8 * 1024;
|
|
||||||
|
|
||||||
type ConfirmBody = {
|
|
||||||
token?: string;
|
|
||||||
deliveryDate?: string;
|
|
||||||
deliveryTime?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
|
|
||||||
|
|
||||||
const resolveRequestedSlot = (
|
|
||||||
invitation: {
|
|
||||||
delivery_date?: string | null;
|
|
||||||
delivery_time?: string | null;
|
|
||||||
available_slots?: string[] | null;
|
|
||||||
},
|
|
||||||
body: ConfirmBody,
|
|
||||||
) => {
|
|
||||||
const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim();
|
|
||||||
const deliveryTime = String(body.deliveryTime || invitation.delivery_time || "").trim();
|
|
||||||
|
|
||||||
if (!deliveryDate || !deliveryTime || !isValidDate(deliveryDate)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const slotLabel = `${deliveryDate}, ${deliveryTime}`;
|
|
||||||
const availableSlots = invitation.available_slots || [];
|
|
||||||
|
|
||||||
if (availableSlots.length > 0 && !availableSlots.includes(slotLabel)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { deliveryDate, deliveryTime };
|
|
||||||
};
|
|
||||||
|
|
||||||
Deno.serve(async (request) => {
|
|
||||||
if (request.method === "OPTIONS") {
|
|
||||||
return preflightResponse(request, "public");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.method !== "POST") {
|
|
||||||
return jsonResponse({ ok: false, error: "Method not allowed" }, 405);
|
|
||||||
}
|
|
||||||
|
|
||||||
const corsHeaders = getCorsHeaders(request, "public");
|
|
||||||
if (!corsHeaders) {
|
|
||||||
return jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
const allowedOriginsForCsrf = ((): string[] => {
|
|
||||||
const envOrigins = (Deno.env.get("APP_ALLOWED_ORIGINS") || "").split(",").map((s: string) => s.trim()).filter(Boolean);
|
|
||||||
const appUrl = Deno.env.get("PUBLIC_APP_URL") || Deno.env.get("APP_PUBLIC_URL") || "";
|
|
||||||
return [...envOrigins, appUrl].filter(Boolean);
|
|
||||||
})();
|
|
||||||
|
|
||||||
if (!requireSameOrigin(request, allowedOriginsForCsrf)) {
|
|
||||||
const origin = request.headers.get("origin") || "";
|
|
||||||
if (origin) {
|
|
||||||
return jsonResponse({ ok: false, error: "Cross-origin request not allowed" }, 403, corsHeaders);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { body } = await readJsonBody<ConfirmBody>(request, {
|
|
||||||
maxBytes: MAX_BODY_BYTES,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!body.token) {
|
|
||||||
return jsonResponse({ ok: false, error: "token is required" }, 400, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.orderGroupId) {
|
|
||||||
try {
|
|
||||||
requireUuid(body.orderGroupId, "orderGroupId");
|
|
||||||
} catch (e) {
|
|
||||||
return jsonResponse({ ok: false, error: (e as Error).message }, 400, corsHeaders);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokenHash = await hashInvitationToken(body.token);
|
|
||||||
const supabase = createServiceClient();
|
|
||||||
const ipHash = await hashText(getClientIp(request));
|
|
||||||
|
|
||||||
await requireRateLimit(supabase, {
|
|
||||||
scope: "invitation-confirm",
|
|
||||||
key: `${ipHash}:${tokenHash.slice(0, 16)}`,
|
|
||||||
maxCount: 5,
|
|
||||||
windowSeconds: 600,
|
|
||||||
blockSeconds: 3600,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: invitation, error: invitationError } = await supabase
|
|
||||||
.from("delivery_invitations")
|
|
||||||
.select("*")
|
|
||||||
.eq("token_hash", tokenHash)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (invitationError) {
|
|
||||||
if (invitationError.code === "PGRST116") {
|
|
||||||
return jsonResponse({ ok: false, error: "Invitation not found" }, 404, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw invitationError;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isInvitationExpired(invitation)) {
|
|
||||||
return jsonResponse({ ok: false, error: "Invitation expired" }, 410, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (invitation.order_group_id) {
|
|
||||||
const { data: currentGroup, error: groupError } = await supabase
|
|
||||||
.from("order_groups")
|
|
||||||
.select("id, delivery_status")
|
|
||||||
.eq("id", invitation.order_group_id)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (groupError) {
|
|
||||||
throw groupError;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isActiveInvitationState(invitation.state) || currentGroup.delivery_status !== "pending_confirmation") {
|
|
||||||
return jsonResponse(
|
|
||||||
{
|
|
||||||
ok: false,
|
|
||||||
error: "Invitation is no longer active",
|
|
||||||
},
|
|
||||||
409,
|
|
||||||
corsHeaders,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestedSlot = resolveRequestedSlot(invitation, body);
|
|
||||||
if (!requestedSlot) {
|
|
||||||
return jsonResponse(
|
|
||||||
{
|
|
||||||
ok: false,
|
|
||||||
error: "Selected slot is not available",
|
|
||||||
},
|
|
||||||
422,
|
|
||||||
corsHeaders,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error: invitationUpdateError } = await supabase
|
|
||||||
.from("delivery_invitations")
|
|
||||||
.update({
|
|
||||||
state: "agreed",
|
|
||||||
delivery_date: requestedSlot.deliveryDate,
|
|
||||||
delivery_time: requestedSlot.deliveryTime,
|
|
||||||
confirmed_at: new Date().toISOString(),
|
|
||||||
access_count: (invitation.access_count || 0) + 1,
|
|
||||||
last_accessed_at: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
.eq("id", invitation.id);
|
|
||||||
|
|
||||||
if (invitationUpdateError) {
|
|
||||||
throw invitationUpdateError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error: groupUpdateError } = await supabase
|
|
||||||
.from("order_groups")
|
|
||||||
.update({
|
|
||||||
delivery_status: "agreed",
|
|
||||||
delivery_date: requestedSlot.deliveryDate,
|
|
||||||
delivery_time: requestedSlot.deliveryTime,
|
|
||||||
notification_status: "confirmed",
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
.eq("id", invitation.order_group_id);
|
|
||||||
|
|
||||||
if (groupUpdateError) {
|
|
||||||
throw groupUpdateError;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log: client confirmed delivery choice
|
|
||||||
await supabase.from("action_logs").insert({
|
|
||||||
order_group_id: invitation.order_group_id,
|
|
||||||
action: "client_confirmed",
|
|
||||||
old_value: currentGroup.delivery_status,
|
|
||||||
new_value: "agreed",
|
|
||||||
details: {
|
|
||||||
delivery_date: requestedSlot.deliveryDate,
|
|
||||||
delivery_time: requestedSlot.deliveryTime,
|
|
||||||
source: "auto",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await insertIntegrationEvent(supabase, {
|
|
||||||
order_id: null,
|
|
||||||
event_type: "delivery_choice_confirmed",
|
|
||||||
direction: "inbound",
|
|
||||||
status: "success",
|
|
||||||
payload: {
|
|
||||||
order_group_id: invitation.order_group_id,
|
|
||||||
delivery_invitation_id: invitation.id,
|
|
||||||
delivery_date: requestedSlot.deliveryDate,
|
|
||||||
delivery_time: requestedSlot.deliveryTime,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return jsonResponse(
|
|
||||||
{
|
|
||||||
ok: true,
|
|
||||||
orderGroupId: invitation.order_group_id,
|
|
||||||
deliveryStatus: "agreed",
|
|
||||||
},
|
|
||||||
200,
|
|
||||||
corsHeaders,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: currentOrder, error: orderError } = await supabase
|
|
||||||
.from("orders")
|
|
||||||
.select("id, status, delivery_agreement_status")
|
|
||||||
.eq("id", invitation.order_id)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (orderError) {
|
|
||||||
throw orderError;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isActiveInvitationState(invitation.state) || !["Ожидает ответа клиента", "Ожидает согласования доставки"].includes(currentOrder.status)) {
|
|
||||||
return jsonResponse(
|
|
||||||
{
|
|
||||||
ok: false,
|
|
||||||
error: "Invitation is no longer active",
|
|
||||||
},
|
|
||||||
409,
|
|
||||||
corsHeaders,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestedSlot = resolveRequestedSlot(invitation, body);
|
|
||||||
if (!requestedSlot) {
|
|
||||||
return jsonResponse(
|
|
||||||
{
|
|
||||||
ok: false,
|
|
||||||
error: "Selected slot is not available",
|
|
||||||
},
|
|
||||||
422,
|
|
||||||
corsHeaders,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const orderUpdate = getOrderUpdateForDeliveryInvitationAction("confirm_delivery_choice");
|
|
||||||
|
|
||||||
const { error: invitationUpdateError } = await supabase
|
|
||||||
.from("delivery_invitations")
|
|
||||||
.update({
|
|
||||||
state: "agreed",
|
|
||||||
delivery_date: requestedSlot.deliveryDate,
|
|
||||||
delivery_time: requestedSlot.deliveryTime,
|
|
||||||
confirmed_at: new Date().toISOString(),
|
|
||||||
access_count: (invitation.access_count || 0) + 1,
|
|
||||||
last_accessed_at: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
.eq("id", invitation.id);
|
|
||||||
|
|
||||||
if (invitationUpdateError) {
|
|
||||||
throw invitationUpdateError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error: orderUpdateError } = await supabase
|
|
||||||
.from("orders")
|
|
||||||
.update({
|
|
||||||
status: orderUpdate?.status,
|
|
||||||
delivery_agreement_status: orderUpdate?.deliveryAgreementStatus,
|
|
||||||
})
|
|
||||||
.eq("id", invitation.order_id);
|
|
||||||
|
|
||||||
if (orderUpdateError) {
|
|
||||||
throw orderUpdateError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error: slotError } = await supabase.from("delivery_slots").insert({
|
|
||||||
order_id: invitation.order_id,
|
|
||||||
delivery_date: requestedSlot.deliveryDate,
|
|
||||||
delivery_time: requestedSlot.deliveryTime,
|
|
||||||
logistician_id: null,
|
|
||||||
status: "confirmed_by_client",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (slotError) {
|
|
||||||
throw slotError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error: historyError } = await supabase.from("order_history").insert({
|
|
||||||
order_id: invitation.order_id,
|
|
||||||
action: "Подтверждение выбора доставки клиентом",
|
|
||||||
old_status: currentOrder.status,
|
|
||||||
new_status: orderUpdate?.status,
|
|
||||||
metadata: {
|
|
||||||
old_delivery_agreement_status: currentOrder.delivery_agreement_status,
|
|
||||||
new_delivery_agreement_status: orderUpdate?.deliveryAgreementStatus,
|
|
||||||
delivery_date: requestedSlot.deliveryDate,
|
|
||||||
delivery_time: requestedSlot.deliveryTime,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (historyError) {
|
|
||||||
throw historyError;
|
|
||||||
}
|
|
||||||
|
|
||||||
await insertIntegrationEvent(supabase, {
|
|
||||||
order_id: invitation.order_id,
|
|
||||||
event_type: "delivery_choice_confirmed",
|
|
||||||
direction: "inbound",
|
|
||||||
status: "success",
|
|
||||||
payload: {
|
|
||||||
delivery_date: requestedSlot.deliveryDate,
|
|
||||||
delivery_time: requestedSlot.deliveryTime,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return jsonResponse(
|
|
||||||
{
|
|
||||||
ok: true,
|
|
||||||
orderId: invitation.order_id,
|
|
||||||
status: orderUpdate?.status,
|
|
||||||
deliveryAgreementStatus: orderUpdate?.deliveryAgreementStatus,
|
|
||||||
},
|
|
||||||
200,
|
|
||||||
corsHeaders,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error && "status" in error) {
|
|
||||||
const httpError = error as { status: number; message: string };
|
|
||||||
return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse(
|
|
||||||
{
|
|
||||||
ok: false,
|
|
||||||
error: error instanceof Error ? error.message : "Unexpected error",
|
|
||||||
},
|
|
||||||
500,
|
|
||||||
corsHeaders,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,409 +0,0 @@
|
||||||
import {
|
|
||||||
buildDefaultDatedAvailableSlots,
|
|
||||||
buildInvitationUrl,
|
|
||||||
generateInvitationToken,
|
|
||||||
getOrderUpdateForDeliveryInvitationAction,
|
|
||||||
hashInvitationToken,
|
|
||||||
normalizeAvailableSlots,
|
|
||||||
resolvePublicAppUrl,
|
|
||||||
} from "../_shared/delivery-invitations.ts";
|
|
||||||
import { channelFromProvider, createServiceClient, json } from "../_shared/chatbot.ts";
|
|
||||||
import { insertIntegrationEvent } from "../_shared/integration-events.ts";
|
|
||||||
import {
|
|
||||||
getClientIp,
|
|
||||||
getCorsHeaders,
|
|
||||||
jsonResponse,
|
|
||||||
readJsonBody,
|
|
||||||
requireRateLimit,
|
|
||||||
verifyInternalRequest,
|
|
||||||
} from "../_shared/security.ts";
|
|
||||||
|
|
||||||
const MAX_BODY_BYTES = 16 * 1024;
|
|
||||||
const MAX_SLOTS = 14;
|
|
||||||
|
|
||||||
type CreateInvitationBody = {
|
|
||||||
orderId?: string;
|
|
||||||
orderGroupId?: string;
|
|
||||||
orderNumber?: string;
|
|
||||||
customerName?: string;
|
|
||||||
customerPhone?: string;
|
|
||||||
customerMessenger?: string;
|
|
||||||
availableSlots?: string[];
|
|
||||||
source?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseGroupKey = (groupKey?: string | null) => {
|
|
||||||
const [phone = "", date = ""] = String(groupKey || "").split("|");
|
|
||||||
return {
|
|
||||||
phone: phone.trim(),
|
|
||||||
date: date.trim(),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveRequiredPublicAppUrl = (request: Request) => {
|
|
||||||
const publicBaseUrl = resolvePublicAppUrl(request);
|
|
||||||
if (!publicBaseUrl) {
|
|
||||||
throw new Error("PUBLIC_APP_URL is not configured");
|
|
||||||
}
|
|
||||||
|
|
||||||
return publicBaseUrl;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createOrderGroupInvitation = async ({
|
|
||||||
body,
|
|
||||||
request,
|
|
||||||
corsHeaders,
|
|
||||||
}: {
|
|
||||||
body: CreateInvitationBody;
|
|
||||||
request: Request;
|
|
||||||
corsHeaders: HeadersInit;
|
|
||||||
}) => {
|
|
||||||
const supabase = createServiceClient();
|
|
||||||
const orderGroupId = String(body.orderGroupId || "").trim();
|
|
||||||
|
|
||||||
await requireRateLimit(supabase, {
|
|
||||||
scope: "delivery-invitation-create",
|
|
||||||
key: orderGroupId,
|
|
||||||
maxCount: 10,
|
|
||||||
windowSeconds: 600,
|
|
||||||
blockSeconds: 1800,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: group, error: groupError } = await supabase
|
|
||||||
.from("order_groups")
|
|
||||||
.select("*")
|
|
||||||
.eq("id", orderGroupId)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (groupError) {
|
|
||||||
throw groupError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedKey = parseGroupKey(group.group_key);
|
|
||||||
const customerName = body.customerName || group.customer_name || group.customer?.name || null;
|
|
||||||
const customerPhone = body.customerPhone || group.customer_phone || group.customer?.phone || parsedKey.phone || null;
|
|
||||||
const orderNumbers = Array.isArray(group.order_numbers) ? group.order_numbers : [];
|
|
||||||
const orderNumber = body.orderNumber || group.group_key || orderNumbers[0] || null;
|
|
||||||
|
|
||||||
if (!customerPhone) {
|
|
||||||
return jsonResponse({ ok: false, error: "customerPhone is required" }, 400, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: existingInvitation, error: existingInvitationError } = await supabase
|
|
||||||
.from("delivery_invitations")
|
|
||||||
.select("id, state")
|
|
||||||
.eq("order_group_id", orderGroupId)
|
|
||||||
.in("state", ["awaiting_choice", "opened", "reminder_sent"])
|
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
if (existingInvitationError) {
|
|
||||||
throw existingInvitationError;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingInvitation) {
|
|
||||||
if (!group.delivery_link) {
|
|
||||||
const { error: revokeInvitationError } = await supabase
|
|
||||||
.from("delivery_invitations")
|
|
||||||
.update({
|
|
||||||
state: "default",
|
|
||||||
revoked_at: new Date().toISOString(),
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
.eq("id", existingInvitation.id);
|
|
||||||
|
|
||||||
if (revokeInvitationError) {
|
|
||||||
throw revokeInvitationError;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return jsonResponse(
|
|
||||||
{
|
|
||||||
ok: true,
|
|
||||||
alreadyStarted: true,
|
|
||||||
invitation: {
|
|
||||||
id: existingInvitation.id,
|
|
||||||
orderGroupId,
|
|
||||||
state: existingInvitation.state,
|
|
||||||
url: group.delivery_link || null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
200,
|
|
||||||
corsHeaders,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingInvitation && !group.delivery_link) {
|
|
||||||
const { error: clearBrokenLinkError } = await supabase
|
|
||||||
.from("order_groups")
|
|
||||||
.update({
|
|
||||||
delivery_invitation_id: null,
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
.eq("id", orderGroupId);
|
|
||||||
|
|
||||||
if (clearBrokenLinkError) {
|
|
||||||
throw clearBrokenLinkError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = generateInvitationToken();
|
|
||||||
const tokenHash = await hashInvitationToken(token);
|
|
||||||
const publicBaseUrl = resolveRequiredPublicAppUrl(request);
|
|
||||||
const url = buildInvitationUrl(publicBaseUrl, token);
|
|
||||||
const availableSlots = body.availableSlots?.length
|
|
||||||
? normalizeAvailableSlots(body.availableSlots).slice(0, MAX_SLOTS)
|
|
||||||
: buildDefaultDatedAvailableSlots();
|
|
||||||
|
|
||||||
const invitationPayload = {
|
|
||||||
order_id: null,
|
|
||||||
order_group_id: orderGroupId,
|
|
||||||
token_hash: tokenHash,
|
|
||||||
state: "awaiting_choice",
|
|
||||||
order_number: orderNumber,
|
|
||||||
customer_name: customerName,
|
|
||||||
customer_phone: customerPhone,
|
|
||||||
customer_messenger: body.customerMessenger || null,
|
|
||||||
available_slots: availableSlots,
|
|
||||||
expires_at: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(),
|
|
||||||
sent_at: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { data: invitation, error: invitationError } = await supabase
|
|
||||||
.from("delivery_invitations")
|
|
||||||
.insert(invitationPayload)
|
|
||||||
.select("id")
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (invitationError) {
|
|
||||||
throw invitationError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error: groupUpdateError } = await supabase
|
|
||||||
.from("order_groups")
|
|
||||||
.update({
|
|
||||||
delivery_invitation_id: invitation.id,
|
|
||||||
delivery_link: url,
|
|
||||||
notification_status: "link_ready",
|
|
||||||
next_notification_check_at: new Date().toISOString(),
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
.eq("id", orderGroupId);
|
|
||||||
|
|
||||||
if (groupUpdateError) {
|
|
||||||
throw groupUpdateError;
|
|
||||||
}
|
|
||||||
|
|
||||||
await insertIntegrationEvent(supabase, {
|
|
||||||
order_id: null,
|
|
||||||
event_type: "delivery_invitation_created",
|
|
||||||
direction: "outbound",
|
|
||||||
status: "success",
|
|
||||||
payload: {
|
|
||||||
order_group_id: orderGroupId,
|
|
||||||
delivery_invitation_id: invitation.id,
|
|
||||||
token_hash: tokenHash,
|
|
||||||
available_slots: availableSlots,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return jsonResponse(
|
|
||||||
{
|
|
||||||
ok: true,
|
|
||||||
invitation: {
|
|
||||||
id: invitation.id,
|
|
||||||
orderGroupId,
|
|
||||||
token,
|
|
||||||
url,
|
|
||||||
state: "awaiting_choice",
|
|
||||||
availableSlots,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
200,
|
|
||||||
corsHeaders,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
Deno.serve(async (request) => {
|
|
||||||
if (request.method === "OPTIONS") {
|
|
||||||
const corsHeaders = getCorsHeaders(request, "integration");
|
|
||||||
return corsHeaders ? new Response("ok", { headers: corsHeaders }) : jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.method !== "POST") {
|
|
||||||
return jsonResponse({ error: "Method not allowed" }, 405);
|
|
||||||
}
|
|
||||||
|
|
||||||
const corsHeaders = getCorsHeaders(request, "integration") || {};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { body, rawBody } = await readJsonBody<CreateInvitationBody>(request, {
|
|
||||||
maxBytes: MAX_BODY_BYTES,
|
|
||||||
});
|
|
||||||
const auth = await verifyInternalRequest(request, rawBody, {
|
|
||||||
rawBody,
|
|
||||||
allowedClockSkewSeconds: 300,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!body.orderId && !body.orderGroupId) {
|
|
||||||
return jsonResponse({ error: "orderId or orderGroupId is required" }, 400, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.orderGroupId) {
|
|
||||||
return await createOrderGroupInvitation({ body, request, corsHeaders });
|
|
||||||
}
|
|
||||||
|
|
||||||
const orderId = body.orderId as string;
|
|
||||||
const supabase = createServiceClient();
|
|
||||||
await requireRateLimit(supabase, {
|
|
||||||
scope: "delivery-invitation-create",
|
|
||||||
key: orderId,
|
|
||||||
maxCount: 10,
|
|
||||||
windowSeconds: 600,
|
|
||||||
blockSeconds: 1800,
|
|
||||||
});
|
|
||||||
|
|
||||||
const token = generateInvitationToken();
|
|
||||||
const tokenHash = await hashInvitationToken(token);
|
|
||||||
const orderUpdate = getOrderUpdateForDeliveryInvitationAction("create_delivery_invitation");
|
|
||||||
|
|
||||||
const { data: currentOrder, error: orderError } = await supabase
|
|
||||||
.from("orders")
|
|
||||||
.select("id, status, delivery_agreement_status, ready_for_delivery_at, delivery_flow_started_at")
|
|
||||||
.eq("id", orderId)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (orderError) {
|
|
||||||
throw orderError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: existingInvitation, error: existingInvitationError } = await supabase
|
|
||||||
.from("delivery_invitations")
|
|
||||||
.select(
|
|
||||||
"id, state, available_slots, order_number, customer_name, customer_phone, customer_messenger, delivery_date, delivery_time, sent_at, opened_at, confirmed_at, expires_at, revoked_at",
|
|
||||||
)
|
|
||||||
.eq("order_id", orderId)
|
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
if (existingInvitationError) {
|
|
||||||
throw existingInvitationError;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentOrder.delivery_flow_started_at || existingInvitation) {
|
|
||||||
return jsonResponse(
|
|
||||||
{
|
|
||||||
ok: true,
|
|
||||||
alreadyStarted: true,
|
|
||||||
invitation: existingInvitation
|
|
||||||
? {
|
|
||||||
orderId,
|
|
||||||
state: existingInvitation.state,
|
|
||||||
availableSlots: existingInvitation.available_slots || [],
|
|
||||||
orderNumber: existingInvitation.order_number || body.orderNumber || null,
|
|
||||||
customerName: existingInvitation.customer_name || body.customerName || null,
|
|
||||||
customerPhone: existingInvitation.customer_phone || body.customerPhone || null,
|
|
||||||
customerMessenger: existingInvitation.customer_messenger || body.customerMessenger || null,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
orderId,
|
|
||||||
state: "awaiting_choice",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
200,
|
|
||||||
corsHeaders,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const invitationPayload = {
|
|
||||||
order_id: orderId,
|
|
||||||
token_hash: tokenHash,
|
|
||||||
state: "awaiting_choice",
|
|
||||||
order_number: body.orderNumber || null,
|
|
||||||
customer_name: body.customerName || null,
|
|
||||||
customer_phone: body.customerPhone || null,
|
|
||||||
customer_messenger: body.customerMessenger || null,
|
|
||||||
available_slots: normalizeAvailableSlots(body.availableSlots),
|
|
||||||
expires_at: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(),
|
|
||||||
sent_at: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const { error: invitationError } = await supabase.from("delivery_invitations").insert(invitationPayload);
|
|
||||||
|
|
||||||
if (invitationError) {
|
|
||||||
throw invitationError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error: updateError } = await supabase
|
|
||||||
.from("orders")
|
|
||||||
.update({
|
|
||||||
status: orderUpdate?.status,
|
|
||||||
delivery_agreement_status: orderUpdate?.deliveryAgreementStatus,
|
|
||||||
ready_for_delivery_at: currentOrder.ready_for_delivery_at || new Date().toISOString(),
|
|
||||||
delivery_flow_started_at: new Date().toISOString(),
|
|
||||||
delivery_flow_source: body.source || "n8n",
|
|
||||||
})
|
|
||||||
.eq("id", orderId);
|
|
||||||
|
|
||||||
if (updateError) {
|
|
||||||
throw updateError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error: historyError } = await supabase.from("order_history").insert({
|
|
||||||
order_id: orderId,
|
|
||||||
action: "Создание приглашения доставки",
|
|
||||||
old_status: currentOrder.status,
|
|
||||||
new_status: orderUpdate?.status,
|
|
||||||
metadata: {
|
|
||||||
old_delivery_agreement_status: currentOrder.delivery_agreement_status,
|
|
||||||
new_delivery_agreement_status: orderUpdate?.deliveryAgreementStatus,
|
|
||||||
channel: channelFromProvider("telegram"),
|
|
||||||
auth: auth.authenticatedBy,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (historyError) {
|
|
||||||
throw historyError;
|
|
||||||
}
|
|
||||||
|
|
||||||
await insertIntegrationEvent(supabase, {
|
|
||||||
order_id: orderId,
|
|
||||||
event_type: "delivery_invitation_created",
|
|
||||||
direction: "outbound",
|
|
||||||
status: "success",
|
|
||||||
payload: {
|
|
||||||
token_hash: tokenHash,
|
|
||||||
available_slots: invitationPayload.available_slots,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const publicBaseUrl = resolveRequiredPublicAppUrl(request);
|
|
||||||
|
|
||||||
return jsonResponse(
|
|
||||||
{
|
|
||||||
ok: true,
|
|
||||||
invitation: {
|
|
||||||
orderId,
|
|
||||||
token,
|
|
||||||
url: buildInvitationUrl(publicBaseUrl, token),
|
|
||||||
state: "awaiting_choice",
|
|
||||||
availableSlots: invitationPayload.available_slots,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
200,
|
|
||||||
corsHeaders,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error && "status" in error) {
|
|
||||||
const httpError = error as { status: number; message: string };
|
|
||||||
return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse(
|
|
||||||
{
|
|
||||||
ok: false,
|
|
||||||
error: error instanceof Error ? error.message : "Unexpected error",
|
|
||||||
},
|
|
||||||
500,
|
|
||||||
corsHeaders,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,191 +0,0 @@
|
||||||
import {
|
|
||||||
buildPublicOrderGroupInvitationView,
|
|
||||||
buildPublicInvitationView,
|
|
||||||
getClientInvitationStateFromOrderGroupStatus,
|
|
||||||
getClientInvitationStateFromOrderStatus,
|
|
||||||
hashInvitationToken,
|
|
||||||
isActiveInvitationState,
|
|
||||||
isInvitationExpired,
|
|
||||||
} from "../_shared/delivery-invitations.ts";
|
|
||||||
import { createServiceClient } from "../_shared/chatbot.ts";
|
|
||||||
import { isValidUuid } from "../_shared/security.ts";
|
|
||||||
import {
|
|
||||||
getClientIp,
|
|
||||||
getCorsHeaders,
|
|
||||||
hashText,
|
|
||||||
jsonResponse,
|
|
||||||
preflightResponse,
|
|
||||||
readJsonBody,
|
|
||||||
requireRateLimit,
|
|
||||||
} from "../_shared/security.ts";
|
|
||||||
|
|
||||||
const MAX_BODY_BYTES = 8 * 1024;
|
|
||||||
|
|
||||||
type InvitationBody = {
|
|
||||||
token?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTokenFromRequest = async (request: Request) => {
|
|
||||||
if (request.method === "GET") {
|
|
||||||
return new URL(request.url).searchParams.get("token") || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const { body } = await readJsonBody<InvitationBody>(request, {
|
|
||||||
maxBytes: MAX_BODY_BYTES,
|
|
||||||
});
|
|
||||||
return String(body.token || "").trim();
|
|
||||||
};
|
|
||||||
|
|
||||||
Deno.serve(async (request) => {
|
|
||||||
if (request.method === "OPTIONS") {
|
|
||||||
return preflightResponse(request, "public");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!["GET", "POST"].includes(request.method)) {
|
|
||||||
return jsonResponse({ ok: false, error: "Method not allowed" }, 405);
|
|
||||||
}
|
|
||||||
|
|
||||||
const corsHeaders = getCorsHeaders(request, "public");
|
|
||||||
if (!corsHeaders) {
|
|
||||||
return jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const token = await getTokenFromRequest(request);
|
|
||||||
if (!token) {
|
|
||||||
return jsonResponse({ ok: false, error: "token is required" }, 400, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokenHash = await hashInvitationToken(token);
|
|
||||||
const supabase = createServiceClient();
|
|
||||||
const ipHash = await hashText(getClientIp(request));
|
|
||||||
|
|
||||||
await requireRateLimit(supabase, {
|
|
||||||
scope: "invitation-get",
|
|
||||||
key: `${ipHash}:${tokenHash.slice(0, 16)}`,
|
|
||||||
maxCount: 30,
|
|
||||||
windowSeconds: 600,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: invitation, error: invitationError } = await supabase
|
|
||||||
.from("delivery_invitations")
|
|
||||||
.select("*")
|
|
||||||
.eq("token_hash", tokenHash)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (invitationError) {
|
|
||||||
if (invitationError.code === "PGRST116") {
|
|
||||||
return jsonResponse({ ok: false, error: "Invitation not found" }, 404, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw invitationError;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isInvitationExpired(invitation)) {
|
|
||||||
return jsonResponse({ ok: false, error: "Invitation expired" }, 410, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (invitation.order_group_id) {
|
|
||||||
const { data: group, error: groupError } = await supabase
|
|
||||||
.from("order_groups")
|
|
||||||
.select("*")
|
|
||||||
.eq("id", invitation.order_group_id)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (groupError) {
|
|
||||||
throw groupError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const publicState = getClientInvitationStateFromOrderGroupStatus(
|
|
||||||
group.delivery_status,
|
|
||||||
invitation.state,
|
|
||||||
);
|
|
||||||
|
|
||||||
await supabase
|
|
||||||
.from("delivery_invitations")
|
|
||||||
.update({
|
|
||||||
opened_at: isActiveInvitationState(publicState) && !invitation.opened_at
|
|
||||||
? new Date().toISOString()
|
|
||||||
: invitation.opened_at,
|
|
||||||
access_count: (invitation.access_count || 0) + 1,
|
|
||||||
last_accessed_at: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
.eq("id", invitation.id);
|
|
||||||
|
|
||||||
const invitationView = buildPublicOrderGroupInvitationView(invitation, group);
|
|
||||||
|
|
||||||
return jsonResponse(
|
|
||||||
{
|
|
||||||
ok: true,
|
|
||||||
invitation: {
|
|
||||||
...invitationView,
|
|
||||||
token,
|
|
||||||
state: publicState,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
200,
|
|
||||||
corsHeaders,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: order, error: orderError } = await supabase
|
|
||||||
.from("orders")
|
|
||||||
.select("id, order_number, status, delivery_agreement_status, customer")
|
|
||||||
.eq("id", invitation.order_id)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (orderError) {
|
|
||||||
throw orderError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const publicState = getClientInvitationStateFromOrderStatus(order.status);
|
|
||||||
|
|
||||||
if (isActiveInvitationState(publicState) && !invitation.opened_at) {
|
|
||||||
await supabase
|
|
||||||
.from("delivery_invitations")
|
|
||||||
.update({
|
|
||||||
opened_at: new Date().toISOString(),
|
|
||||||
access_count: (invitation.access_count || 0) + 1,
|
|
||||||
last_accessed_at: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
.eq("id", invitation.id);
|
|
||||||
} else {
|
|
||||||
await supabase
|
|
||||||
.from("delivery_invitations")
|
|
||||||
.update({
|
|
||||||
access_count: (invitation.access_count || 0) + 1,
|
|
||||||
last_accessed_at: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
.eq("id", invitation.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const invitationView = buildPublicInvitationView(invitation, order);
|
|
||||||
|
|
||||||
return jsonResponse(
|
|
||||||
{
|
|
||||||
ok: true,
|
|
||||||
invitation: {
|
|
||||||
...invitationView,
|
|
||||||
token,
|
|
||||||
state: publicState,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
200,
|
|
||||||
corsHeaders,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error && "status" in error) {
|
|
||||||
const httpError = error as { status: number; message: string };
|
|
||||||
return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse(
|
|
||||||
{
|
|
||||||
ok: false,
|
|
||||||
error: error instanceof Error ? error.message : "Unexpected error",
|
|
||||||
},
|
|
||||||
500,
|
|
||||||
corsHeaders,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
{
|
|
||||||
"imports": {
|
|
||||||
"@supabase/supabase-js": "https://esm.sh/@supabase/supabase-js@2.49.8"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,168 +0,0 @@
|
||||||
import * as jose from 'https://deno.land/x/jose@v4.14.4/index.ts'
|
|
||||||
|
|
||||||
console.log('main function started')
|
|
||||||
|
|
||||||
const JWT_SECRET = Deno.env.get('JWT_SECRET')
|
|
||||||
const SUPABASE_URL = Deno.env.get('SUPABASE_URL')
|
|
||||||
const VERIFY_JWT = Deno.env.get('VERIFY_JWT') === 'true'
|
|
||||||
|
|
||||||
// Create JWKS for ES256/RS256 tokens (newer tokens)
|
|
||||||
let SUPABASE_JWT_KEYS: ReturnType<typeof jose.createRemoteJWKSet> | null = null
|
|
||||||
if (SUPABASE_URL) {
|
|
||||||
try {
|
|
||||||
SUPABASE_JWT_KEYS = jose.createRemoteJWKSet(
|
|
||||||
new URL('/auth/v1/.well-known/jwks.json', SUPABASE_URL)
|
|
||||||
)
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to fetch JWKS from SUPABASE_URL:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract JWT token from Authorization header
|
|
||||||
*
|
|
||||||
* Parses the Authorization header to extract the Bearer token.
|
|
||||||
* Expects format: "Bearer <token>"
|
|
||||||
*
|
|
||||||
* @param req - The HTTP request object
|
|
||||||
* @returns The JWT token string
|
|
||||||
* @throws Error if Authorization header is missing or malformed
|
|
||||||
*/
|
|
||||||
function getAuthToken(req: Request) {
|
|
||||||
const authHeader = req.headers.get('authorization')
|
|
||||||
if (!authHeader) {
|
|
||||||
throw new Error('Missing authorization header')
|
|
||||||
}
|
|
||||||
const [bearer, token] = authHeader.split(' ')
|
|
||||||
if (bearer !== 'Bearer') {
|
|
||||||
throw new Error(`Auth header is not 'Bearer {token}'`)
|
|
||||||
}
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
|
|
||||||
async function isValidLegacyJWT(jwt: string): Promise<boolean> {
|
|
||||||
if (!JWT_SECRET) {
|
|
||||||
console.error('JWT_SECRET not available for HS256 token verification')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
const secretKey = encoder.encode(JWT_SECRET)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await jose.jwtVerify(jwt, secretKey);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Symmetric Legacy JWT verification error', e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function isValidJWT(jwt: string): Promise<boolean> {
|
|
||||||
if (!SUPABASE_JWT_KEYS) {
|
|
||||||
console.error('JWKS not available for ES256/RS256 token verification')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await jose.jwtVerify(jwt, SUPABASE_JWT_KEYS)
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Asymmetric JWT verification error', e);
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify JWT token, handling both legacy (HS256) and newer (ES256/RS256) algorithms
|
|
||||||
*
|
|
||||||
* This function automatically detects the algorithm used in the token and applies
|
|
||||||
* the appropriate verification method:
|
|
||||||
* - HS256: Uses JWT_SECRET (symmetric key)
|
|
||||||
* - ES256/RS256: Uses JWKS endpoint (asymmetric public keys)
|
|
||||||
*
|
|
||||||
* This fix ensures compatibility with both legacy tokens and newer asymmetric tokens,
|
|
||||||
* resolving the "Key for the ES256 algorithm must be of type CryptoKey" error.
|
|
||||||
*
|
|
||||||
* @param jwt - The JWT token string to verify
|
|
||||||
* @returns Promise resolving to true if verification succeeds, false otherwise
|
|
||||||
*/
|
|
||||||
async function isValidHybridJWT(jwt: string): Promise<boolean> {
|
|
||||||
const { alg: jwtAlgorithm } = jose.decodeProtectedHeader(jwt)
|
|
||||||
|
|
||||||
if (jwtAlgorithm === 'HS256') {
|
|
||||||
console.log(`Legacy token type detected, attempting ${jwtAlgorithm} verification.`)
|
|
||||||
|
|
||||||
return await isValidLegacyJWT(jwt)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (jwtAlgorithm === 'ES256' || jwtAlgorithm === 'RS256') {
|
|
||||||
return await isValidJWT(jwt)
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
Deno.serve(async (req: Request) => {
|
|
||||||
if (req.method !== 'OPTIONS' && VERIFY_JWT) {
|
|
||||||
try {
|
|
||||||
const token = getAuthToken(req)
|
|
||||||
const isValidJWT = await isValidHybridJWT(token);
|
|
||||||
|
|
||||||
if (!isValidJWT) {
|
|
||||||
return new Response(JSON.stringify({ msg: 'Invalid JWT' }), {
|
|
||||||
status: 401,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
return new Response(JSON.stringify({ msg: e.toString() }), {
|
|
||||||
status: 401,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = new URL(req.url)
|
|
||||||
const { pathname } = url
|
|
||||||
const path_parts = pathname.split('/')
|
|
||||||
const service_name = path_parts[1]
|
|
||||||
|
|
||||||
if (!service_name || service_name === '') {
|
|
||||||
const error = { msg: 'missing function name in request' }
|
|
||||||
return new Response(JSON.stringify(error), {
|
|
||||||
status: 400,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const servicePath = `/home/deno/functions/${service_name}`
|
|
||||||
console.error(`serving the request with ${servicePath}`)
|
|
||||||
|
|
||||||
const memoryLimitMb = 150
|
|
||||||
const workerTimeoutMs = 1 * 60 * 1000
|
|
||||||
const noModuleCache = false
|
|
||||||
const importMapPath = "/home/deno/functions/import_map.json"
|
|
||||||
const envVarsObj = Deno.env.toObject()
|
|
||||||
const envVars = Object.keys(envVarsObj).map((k) => [k, envVarsObj[k]])
|
|
||||||
|
|
||||||
try {
|
|
||||||
const worker = await EdgeRuntime.userWorkers.create({
|
|
||||||
servicePath,
|
|
||||||
memoryLimitMb,
|
|
||||||
workerTimeoutMs,
|
|
||||||
noModuleCache,
|
|
||||||
importMapPath,
|
|
||||||
envVars,
|
|
||||||
})
|
|
||||||
return await worker.fetch(req)
|
|
||||||
} catch (e) {
|
|
||||||
const error = { msg: e.toString() }
|
|
||||||
return new Response(JSON.stringify(error), {
|
|
||||||
status: 500,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
import { getOrderUpdateForDeliveryInvitationAction } from "../_shared/delivery-invitations.ts";
|
|
||||||
import { requireUuid } from "../_shared/security.ts";
|
|
||||||
import { createServiceClient } from "../_shared/chatbot.ts";
|
|
||||||
import { insertIntegrationEvent } from "../_shared/integration-events.ts";
|
|
||||||
import {
|
|
||||||
getCorsHeaders,
|
|
||||||
jsonResponse,
|
|
||||||
readJsonBody,
|
|
||||||
requireRateLimit,
|
|
||||||
verifyInternalRequest,
|
|
||||||
} from "../_shared/security.ts";
|
|
||||||
|
|
||||||
const MAX_BODY_BYTES = 16 * 1024;
|
|
||||||
|
|
||||||
type ReportBody = {
|
|
||||||
orderId?: string;
|
|
||||||
result?: "delivered" | "problem";
|
|
||||||
note?: string;
|
|
||||||
payload?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
Deno.serve(async (request) => {
|
|
||||||
if (request.method === "OPTIONS") {
|
|
||||||
const corsHeaders = getCorsHeaders(request, "integration");
|
|
||||||
return corsHeaders ? new Response("ok", { headers: corsHeaders }) : jsonResponse({ error: "Origin not allowed" }, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.method !== "POST") {
|
|
||||||
return jsonResponse({ error: "Method not allowed" }, 405);
|
|
||||||
}
|
|
||||||
|
|
||||||
const corsHeaders = getCorsHeaders(request, "integration") || {};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { body, rawBody } = await readJsonBody<ReportBody>(request, {
|
|
||||||
maxBytes: MAX_BODY_BYTES,
|
|
||||||
});
|
|
||||||
await verifyInternalRequest(request, rawBody, { rawBody });
|
|
||||||
|
|
||||||
if (!body.orderId) {
|
|
||||||
return jsonResponse({ error: "orderId is required" }, 400, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
requireUuid(body.orderId, "orderId");
|
|
||||||
} catch (e) {
|
|
||||||
return jsonResponse({ ok: false, error: (e as Error).message }, 400, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabase = createServiceClient();
|
|
||||||
await requireRateLimit(supabase, {
|
|
||||||
scope: "delivery-report",
|
|
||||||
key: body.orderId,
|
|
||||||
maxCount: 10,
|
|
||||||
windowSeconds: 600,
|
|
||||||
blockSeconds: 1800,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: currentOrder, error: orderError } = await supabase
|
|
||||||
.from("orders")
|
|
||||||
.select("id, status, delivery_agreement_status")
|
|
||||||
.eq("id", body.orderId)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (orderError) {
|
|
||||||
throw orderError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDelivered = body.result === "delivered";
|
|
||||||
const action = isDelivered ? "mark_delivered" : "mark_paid_storage";
|
|
||||||
const orderUpdate = getOrderUpdateForDeliveryInvitationAction(action);
|
|
||||||
const nextStatus = isDelivered ? orderUpdate?.status || "Доставлен" : "Проблема доставки";
|
|
||||||
|
|
||||||
const { error: invitationError } = await supabase
|
|
||||||
.from("delivery_invitations")
|
|
||||||
.update({
|
|
||||||
state: isDelivered ? "delivered" : "paid_storage",
|
|
||||||
...(isDelivered ? { delivered_at: new Date().toISOString() } : { paid_storage_at: new Date().toISOString() }),
|
|
||||||
})
|
|
||||||
.eq("order_id", body.orderId);
|
|
||||||
|
|
||||||
if (invitationError) {
|
|
||||||
throw invitationError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error: updateError } = await supabase
|
|
||||||
.from("orders")
|
|
||||||
.update({
|
|
||||||
status: nextStatus,
|
|
||||||
delivery_agreement_status: isDelivered
|
|
||||||
? "Подтверждено клиентом"
|
|
||||||
: body.note || currentOrder.delivery_agreement_status || "Ошибка отправки",
|
|
||||||
})
|
|
||||||
.eq("id", body.orderId);
|
|
||||||
|
|
||||||
if (updateError) {
|
|
||||||
throw updateError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error: historyError } = await supabase.from("order_history").insert({
|
|
||||||
order_id: body.orderId,
|
|
||||||
action: isDelivered ? "Подтверждение доставки" : "Фиксация проблемы доставки",
|
|
||||||
old_status: currentOrder.status,
|
|
||||||
new_status: isDelivered ? "Доставлен" : "Проблема доставки",
|
|
||||||
metadata: {
|
|
||||||
old_delivery_agreement_status: currentOrder.delivery_agreement_status,
|
|
||||||
new_delivery_agreement_status: isDelivered
|
|
||||||
? "Подтверждено клиентом"
|
|
||||||
: body.note || currentOrder.delivery_agreement_status || "Ошибка отправки",
|
|
||||||
payload: body.payload || {},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (historyError) {
|
|
||||||
throw historyError;
|
|
||||||
}
|
|
||||||
|
|
||||||
await insertIntegrationEvent(supabase, {
|
|
||||||
order_id: body.orderId,
|
|
||||||
event_type: isDelivered ? "delivery_result_delivered" : "delivery_result_problem",
|
|
||||||
direction: "internal",
|
|
||||||
status: "success",
|
|
||||||
payload: {
|
|
||||||
result: body.result || null,
|
|
||||||
note: body.note || null,
|
|
||||||
payload: body.payload || {},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return jsonResponse(
|
|
||||||
{
|
|
||||||
ok: true,
|
|
||||||
orderId: body.orderId,
|
|
||||||
status: nextStatus,
|
|
||||||
deliveryAgreementStatus: isDelivered
|
|
||||||
? "Подтверждено клиентом"
|
|
||||||
: body.note || currentOrder.delivery_agreement_status || "Ошибка отправки",
|
|
||||||
workflowStatus: nextStatus,
|
|
||||||
},
|
|
||||||
200,
|
|
||||||
corsHeaders,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error && "status" in error) {
|
|
||||||
const httpError = error as { status: number; message: string };
|
|
||||||
return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse(
|
|
||||||
{
|
|
||||||
ok: false,
|
|
||||||
error: error instanceof Error ? error.message : "Unexpected error",
|
|
||||||
},
|
|
||||||
500,
|
|
||||||
corsHeaders,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
import { createServiceClient } from "../_shared/security.ts";
|
|
||||||
import {
|
|
||||||
getClientIp,
|
|
||||||
getCorsHeaders,
|
|
||||||
hashText,
|
|
||||||
jsonResponse,
|
|
||||||
preflightResponse,
|
|
||||||
readJsonBody,
|
|
||||||
requireRateLimit,
|
|
||||||
} from "../_shared/security.ts";
|
|
||||||
|
|
||||||
const MAX_BODY_BYTES = 8 * 1024;
|
|
||||||
|
|
||||||
const isValidEmail = (value: string) =>
|
|
||||||
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
|
|
||||||
|
|
||||||
function generateOtp(): string {
|
|
||||||
const digits = "0123456789";
|
|
||||||
let otp = "";
|
|
||||||
const arr = new Uint8Array(6);
|
|
||||||
crypto.getRandomValues(arr);
|
|
||||||
for (let i = 0; i < 6; i++) {
|
|
||||||
otp += digits[arr[i] % digits.length];
|
|
||||||
}
|
|
||||||
return otp;
|
|
||||||
}
|
|
||||||
|
|
||||||
Deno.serve(async (request) => {
|
|
||||||
if (request.method === "OPTIONS") {
|
|
||||||
return preflightResponse(request, "public");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.method !== "POST") {
|
|
||||||
return jsonResponse({ ok: false, error: "Method not allowed" }, 405);
|
|
||||||
}
|
|
||||||
|
|
||||||
const corsHeaders = getCorsHeaders(request, "public");
|
|
||||||
if (!corsHeaders) {
|
|
||||||
return jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { body } = await readJsonBody<{ email?: string }>(request, {
|
|
||||||
maxBytes: MAX_BODY_BYTES,
|
|
||||||
});
|
|
||||||
const email = String(body.email || "").trim().toLowerCase();
|
|
||||||
|
|
||||||
if (!email || !isValidEmail(email)) {
|
|
||||||
return jsonResponse({ ok: false, error: "Valid email is required" }, 400, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabase = createServiceClient();
|
|
||||||
const emailHash = await hashText(email);
|
|
||||||
const ipHash = await hashText(getClientIp(request));
|
|
||||||
|
|
||||||
await requireRateLimit(supabase, {
|
|
||||||
scope: "otp-request",
|
|
||||||
key: `${ipHash}:${emailHash}`,
|
|
||||||
maxCount: 3,
|
|
||||||
windowSeconds: 600,
|
|
||||||
blockSeconds: 1800,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if user exists in our users table
|
|
||||||
const { data: users, error: userError } = await supabase
|
|
||||||
.from("users")
|
|
||||||
.select("id, name, roles(name)")
|
|
||||||
.eq("email", email)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (userError || !users || users.length === 0) {
|
|
||||||
return jsonResponse({ ok: false, error: "Email не найден в системе. Обратитесь к администратору." }, 400, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = users[0];
|
|
||||||
const userName = user.name || null;
|
|
||||||
const userRole = user.roles?.name || null;
|
|
||||||
|
|
||||||
// Invalidate previous unverified OTPs for this email
|
|
||||||
await supabase
|
|
||||||
.from("login_otps")
|
|
||||||
.delete()
|
|
||||||
.eq("email", email)
|
|
||||||
.eq("verified", false);
|
|
||||||
|
|
||||||
// Generate OTP
|
|
||||||
const otp = generateOtp();
|
|
||||||
const otpCodeHash = await hashText(otp);
|
|
||||||
const clientIp = getClientIp(request);
|
|
||||||
const userAgent = request.headers.get("user-agent") || null;
|
|
||||||
|
|
||||||
// Insert with plaintext otp_code so DB webhook "send_pin" delivers it to n8n
|
|
||||||
// n8n will clear otp_code after sending SMS
|
|
||||||
const { error: insertError } = await supabase.from("login_otps").insert({
|
|
||||||
email,
|
|
||||||
name: userName,
|
|
||||||
role: userRole,
|
|
||||||
otp_code: otp,
|
|
||||||
otp_code_hash: otpCodeHash,
|
|
||||||
ip_address: clientIp,
|
|
||||||
user_agent: userAgent,
|
|
||||||
verified: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (insertError) {
|
|
||||||
console.error("Failed to insert OTP:", insertError);
|
|
||||||
return jsonResponse({ ok: false, error: "Failed to generate OTP" }, 500, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse({ ok: true }, 200, corsHeaders);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error && "status" in error) {
|
|
||||||
const httpError = error as { status: number; message: string };
|
|
||||||
return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse(
|
|
||||||
{
|
|
||||||
ok: false,
|
|
||||||
error: error instanceof Error ? error.message : "Unexpected error",
|
|
||||||
},
|
|
||||||
500,
|
|
||||||
corsHeaders,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
import {
|
|
||||||
channelFromProvider,
|
|
||||||
createServiceClient,
|
|
||||||
json,
|
|
||||||
type ProviderName,
|
|
||||||
} from "../_shared/chatbot.ts";
|
|
||||||
import { getOrderUpdateForOutboundDispatch, type OutboundWorkflowAction } from "../_shared/workflow.ts";
|
|
||||||
import {
|
|
||||||
getCorsHeaders,
|
|
||||||
readJsonBody,
|
|
||||||
requireRateLimit,
|
|
||||||
verifyInternalRequest,
|
|
||||||
} from "../_shared/security.ts";
|
|
||||||
|
|
||||||
const providerTokens: Record<ProviderName, string | undefined> = {
|
|
||||||
telegram: Deno.env.get("TELEGRAM_BOT_TOKEN"),
|
|
||||||
vk: Deno.env.get("VK_BOT_TOKEN"),
|
|
||||||
messenger_max: Deno.env.get("MESSENGER_MAX_TOKEN"),
|
|
||||||
};
|
|
||||||
|
|
||||||
const MAX_BODY_BYTES = 16 * 1024;
|
|
||||||
|
|
||||||
const sendToProvider = async ({
|
|
||||||
provider,
|
|
||||||
recipientId,
|
|
||||||
text,
|
|
||||||
buttons,
|
|
||||||
}: {
|
|
||||||
provider: ProviderName;
|
|
||||||
recipientId: string;
|
|
||||||
text: string;
|
|
||||||
buttons?: Array<{ title: string; action: string }>;
|
|
||||||
}) => {
|
|
||||||
const token = providerTokens[provider];
|
|
||||||
if (!token) {
|
|
||||||
throw new Error(`Missing token for ${provider}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
provider,
|
|
||||||
recipientId,
|
|
||||||
text,
|
|
||||||
buttons: buttons || [],
|
|
||||||
accepted: true,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
Deno.serve(async (request) => {
|
|
||||||
if (request.method === "OPTIONS") {
|
|
||||||
const corsHeaders = getCorsHeaders(request, "integration");
|
|
||||||
return corsHeaders ? new Response("ok", { headers: corsHeaders }) : json({ error: "Origin not allowed" }, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.method !== "POST") {
|
|
||||||
return json({ error: "Method not allowed" }, 405);
|
|
||||||
}
|
|
||||||
|
|
||||||
const corsHeaders = getCorsHeaders(request, "integration") || {};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { body, rawBody } = await readJsonBody<{
|
|
||||||
provider: ProviderName;
|
|
||||||
orderId: string;
|
|
||||||
recipientId: string;
|
|
||||||
text: string;
|
|
||||||
buttons?: Array<{ title: string; action: string }>;
|
|
||||||
workflowAction?: OutboundWorkflowAction;
|
|
||||||
}>(request, {
|
|
||||||
maxBytes: MAX_BODY_BYTES,
|
|
||||||
});
|
|
||||||
|
|
||||||
await verifyInternalRequest(request, rawBody, { rawBody });
|
|
||||||
|
|
||||||
const supabase = createServiceClient();
|
|
||||||
await requireRateLimit(supabase, {
|
|
||||||
scope: "chatbot-dispatch",
|
|
||||||
key: body.orderId,
|
|
||||||
maxCount: 10,
|
|
||||||
windowSeconds: 600,
|
|
||||||
blockSeconds: 1800,
|
|
||||||
});
|
|
||||||
|
|
||||||
const dispatchResult = await sendToProvider(body);
|
|
||||||
|
|
||||||
const { error } = await supabase.from("chat_messages").insert({
|
|
||||||
order_id: body.orderId,
|
|
||||||
sender_name: "dispatch-function",
|
|
||||||
sender_type: "bot",
|
|
||||||
channel: channelFromProvider(body.provider),
|
|
||||||
text: body.text,
|
|
||||||
payload: {
|
|
||||||
buttons: body.buttons || [],
|
|
||||||
dispatch_result: dispatchResult,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const orderUpdate = getOrderUpdateForOutboundDispatch(body.workflowAction || "custom_message");
|
|
||||||
if (orderUpdate) {
|
|
||||||
const { data: currentOrder, error: orderError } = await supabase
|
|
||||||
.from("orders")
|
|
||||||
.select("id, status, delivery_agreement_status")
|
|
||||||
.eq("id", body.orderId)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (orderError) {
|
|
||||||
throw orderError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error: updateError } = await supabase
|
|
||||||
.from("orders")
|
|
||||||
.update({
|
|
||||||
status: orderUpdate.status,
|
|
||||||
delivery_agreement_status: orderUpdate.deliveryAgreementStatus,
|
|
||||||
})
|
|
||||||
.eq("id", body.orderId);
|
|
||||||
|
|
||||||
if (updateError) {
|
|
||||||
throw updateError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error: historyError } = await supabase.from("order_history").insert({
|
|
||||||
order_id: body.orderId,
|
|
||||||
action: `Dispatch ${body.provider}: ${body.workflowAction || "custom_message"}`,
|
|
||||||
old_status: currentOrder.status,
|
|
||||||
new_status: orderUpdate.status,
|
|
||||||
metadata: {
|
|
||||||
old_delivery_agreement_status: currentOrder.delivery_agreement_status,
|
|
||||||
new_delivery_agreement_status: orderUpdate.deliveryAgreementStatus,
|
|
||||||
buttons: body.buttons || [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (historyError) {
|
|
||||||
throw historyError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return json({ ok: true, dispatchResult });
|
|
||||||
} catch (error) {
|
|
||||||
return json(
|
|
||||||
{
|
|
||||||
ok: false,
|
|
||||||
error: error instanceof Error ? error.message : "Unexpected error",
|
|
||||||
},
|
|
||||||
500,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
import {
|
|
||||||
getOrderUpdateForDeliveryInvitationAction,
|
|
||||||
} from "../_shared/delivery-invitations.ts";
|
|
||||||
import { createServiceClient } from "../_shared/chatbot.ts";
|
|
||||||
import { insertIntegrationEvent } from "../_shared/integration-events.ts";
|
|
||||||
import {
|
|
||||||
getCorsHeaders,
|
|
||||||
jsonResponse,
|
|
||||||
readJsonBody,
|
|
||||||
requireRateLimit,
|
|
||||||
verifyInternalRequest,
|
|
||||||
} from "../_shared/security.ts";
|
|
||||||
|
|
||||||
const MAX_BODY_BYTES = 16 * 1024;
|
|
||||||
|
|
||||||
type TransferBody = {
|
|
||||||
orderId?: string;
|
|
||||||
reason?: string;
|
|
||||||
note?: string;
|
|
||||||
targetStatus?: "Передан логисту" | "Платное хранение";
|
|
||||||
};
|
|
||||||
|
|
||||||
Deno.serve(async (request) => {
|
|
||||||
if (request.method === "OPTIONS") {
|
|
||||||
const corsHeaders = getCorsHeaders(request, "integration");
|
|
||||||
return corsHeaders ? new Response("ok", { headers: corsHeaders }) : jsonResponse({ error: "Origin not allowed" }, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.method !== "POST") {
|
|
||||||
return jsonResponse({ error: "Method not allowed" }, 405);
|
|
||||||
}
|
|
||||||
|
|
||||||
const corsHeaders = getCorsHeaders(request, "integration") || {};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { body, rawBody } = await readJsonBody<TransferBody>(request, {
|
|
||||||
maxBytes: MAX_BODY_BYTES,
|
|
||||||
});
|
|
||||||
await verifyInternalRequest(request, rawBody, { rawBody });
|
|
||||||
|
|
||||||
if (!body.orderId) {
|
|
||||||
return jsonResponse({ error: "orderId is required" }, 400, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
requireUuid(body.orderId, "orderId");
|
|
||||||
} catch (e) {
|
|
||||||
return jsonResponse({ ok: false, error: (e as Error).message }, 400, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabase = createServiceClient();
|
|
||||||
await requireRateLimit(supabase, {
|
|
||||||
scope: "delivery-transfer",
|
|
||||||
key: body.orderId,
|
|
||||||
maxCount: 10,
|
|
||||||
windowSeconds: 600,
|
|
||||||
blockSeconds: 1800,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: currentOrder, error: orderError } = await supabase
|
|
||||||
.from("orders")
|
|
||||||
.select("id, status, delivery_agreement_status")
|
|
||||||
.eq("id", body.orderId)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (orderError) {
|
|
||||||
throw orderError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetStatus = body.targetStatus || "Передан логисту";
|
|
||||||
const action = targetStatus === "Платное хранение" ? "mark_paid_storage" : "transfer_to_logistics";
|
|
||||||
const orderUpdate = getOrderUpdateForDeliveryInvitationAction(action);
|
|
||||||
|
|
||||||
const { error: invitationError } = await supabase
|
|
||||||
.from("delivery_invitations")
|
|
||||||
.update({
|
|
||||||
state: targetStatus === "Платное хранение" ? "paid_storage" : "transferred_to_logistics",
|
|
||||||
...(targetStatus === "Платное хранение"
|
|
||||||
? { paid_storage_at: new Date().toISOString() }
|
|
||||||
: { logistics_transferred_at: new Date().toISOString() }),
|
|
||||||
})
|
|
||||||
.eq("order_id", body.orderId);
|
|
||||||
|
|
||||||
if (invitationError) {
|
|
||||||
throw invitationError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error: updateError } = await supabase
|
|
||||||
.from("orders")
|
|
||||||
.update({
|
|
||||||
status: orderUpdate?.status,
|
|
||||||
delivery_agreement_status: orderUpdate?.deliveryAgreementStatus,
|
|
||||||
})
|
|
||||||
.eq("id", body.orderId);
|
|
||||||
|
|
||||||
if (updateError) {
|
|
||||||
throw updateError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error: historyError } = await supabase.from("order_history").insert({
|
|
||||||
order_id: body.orderId,
|
|
||||||
action: targetStatus === "Платное хранение" ? "Перевод на платное хранение" : "Передача заказа логисту",
|
|
||||||
old_status: currentOrder.status,
|
|
||||||
new_status: orderUpdate?.status,
|
|
||||||
metadata: {
|
|
||||||
old_delivery_agreement_status: currentOrder.delivery_agreement_status,
|
|
||||||
new_delivery_agreement_status: orderUpdate?.deliveryAgreementStatus,
|
|
||||||
reason: body.reason || null,
|
|
||||||
note: body.note || null,
|
|
||||||
target_status: targetStatus,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (historyError) {
|
|
||||||
throw historyError;
|
|
||||||
}
|
|
||||||
|
|
||||||
await insertIntegrationEvent(supabase, {
|
|
||||||
order_id: body.orderId,
|
|
||||||
event_type:
|
|
||||||
targetStatus === "Платное хранение" ? "delivery_paid_storage_requested" : "delivery_transfer_to_logistics",
|
|
||||||
direction: "internal",
|
|
||||||
status: "success",
|
|
||||||
payload: {
|
|
||||||
reason: body.reason || null,
|
|
||||||
note: body.note || null,
|
|
||||||
target_status: targetStatus,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return jsonResponse(
|
|
||||||
{
|
|
||||||
ok: true,
|
|
||||||
orderId: body.orderId,
|
|
||||||
status: orderUpdate?.status,
|
|
||||||
deliveryAgreementStatus: orderUpdate?.deliveryAgreementStatus,
|
|
||||||
},
|
|
||||||
200,
|
|
||||||
corsHeaders,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error && "status" in error) {
|
|
||||||
const httpError = error as { status: number; message: string };
|
|
||||||
return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse(
|
|
||||||
{
|
|
||||||
ok: false,
|
|
||||||
error: error instanceof Error ? error.message : "Unexpected error",
|
|
||||||
},
|
|
||||||
500,
|
|
||||||
corsHeaders,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,230 +0,0 @@
|
||||||
import { createServiceClient } from "../_shared/chatbot.ts";
|
|
||||||
import { insertIntegrationEvent } from "../_shared/integration-events.ts";
|
|
||||||
import {
|
|
||||||
getClientIp,
|
|
||||||
getCorsHeaders,
|
|
||||||
hashText,
|
|
||||||
jsonResponse,
|
|
||||||
preflightResponse,
|
|
||||||
readJsonBody,
|
|
||||||
requireRateLimit,
|
|
||||||
} from "../_shared/security.ts";
|
|
||||||
|
|
||||||
const MAX_BODY_BYTES = 8 * 1024;
|
|
||||||
const ALLOWED_ROLES = new Set(["manager", "logistician", "admin"]);
|
|
||||||
const ALLOWED_DELIVERY_TIMES = new Set(["Первая половина дня", "Вторая половина дня"]);
|
|
||||||
const DELIVERY_TIME_ALIASES = new Map([
|
|
||||||
["До обеда", "Первая половина дня"],
|
|
||||||
["После обеда", "Вторая половина дня"],
|
|
||||||
]);
|
|
||||||
const DELIVERY_TIMEZONE = "Europe/Simferopol";
|
|
||||||
|
|
||||||
type UpdateDeliveryChoiceBody = {
|
|
||||||
orderGroupId?: string;
|
|
||||||
deliveryDate?: string;
|
|
||||||
deliveryTime?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
|
|
||||||
|
|
||||||
const getTodayKey = () => {
|
|
||||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
||||||
timeZone: DELIVERY_TIMEZONE,
|
|
||||||
year: "numeric",
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
}).formatToParts(new Date());
|
|
||||||
|
|
||||||
const year = parts.find((part) => part.type === "year")?.value || "";
|
|
||||||
const month = parts.find((part) => part.type === "month")?.value || "";
|
|
||||||
const day = parts.find((part) => part.type === "day")?.value || "";
|
|
||||||
|
|
||||||
return `${year}-${month}-${day}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isWeekendDeliveryDate = (value: string) => {
|
|
||||||
if (!isValidDate(value)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = new Date(`${value}T12:00:00Z`);
|
|
||||||
const weekday = date.getUTCDay();
|
|
||||||
return weekday === 0 || weekday === 6;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isAllowedDeliveryDate = (value: string) => isValidDate(value) && value > getTodayKey() && !isWeekendDeliveryDate(value);
|
|
||||||
|
|
||||||
const normalizeDeliveryTime = (value: string) => DELIVERY_TIME_ALIASES.get(value) || value;
|
|
||||||
|
|
||||||
const getBearerToken = (request: Request) => {
|
|
||||||
const authorization = request.headers.get("authorization") || "";
|
|
||||||
return authorization.toLowerCase().startsWith("bearer ")
|
|
||||||
? authorization.slice(7).trim()
|
|
||||||
: "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const getUserRole = async (
|
|
||||||
supabase: ReturnType<typeof createServiceClient>,
|
|
||||||
accessToken: string,
|
|
||||||
) => {
|
|
||||||
const { data: authData, error: authError } = await supabase.auth.getUser(accessToken);
|
|
||||||
if (authError || !authData.user?.id) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: profile, error: profileError } = await supabase
|
|
||||||
.from("users")
|
|
||||||
.select("id, role_info:roles(name)")
|
|
||||||
.eq("id", authData.user.id)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (profileError) {
|
|
||||||
throw profileError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const roleInfo = Array.isArray(profile.role_info) ? profile.role_info[0] : profile.role_info;
|
|
||||||
return {
|
|
||||||
userId: authData.user.id,
|
|
||||||
role: roleInfo?.name || "",
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
Deno.serve(async (request) => {
|
|
||||||
if (request.method === "OPTIONS") {
|
|
||||||
return preflightResponse(request, "public");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.method !== "POST") {
|
|
||||||
return jsonResponse({ ok: false, error: "Method not allowed" }, 405);
|
|
||||||
}
|
|
||||||
|
|
||||||
const corsHeaders = getCorsHeaders(request, "public");
|
|
||||||
if (!corsHeaders) {
|
|
||||||
return jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { body } = await readJsonBody<UpdateDeliveryChoiceBody>(request, {
|
|
||||||
maxBytes: MAX_BODY_BYTES,
|
|
||||||
});
|
|
||||||
|
|
||||||
const orderGroupId = String(body.orderGroupId || "").trim();
|
|
||||||
const deliveryDate = String(body.deliveryDate || "").trim();
|
|
||||||
const deliveryTime = normalizeDeliveryTime(String(body.deliveryTime || "").trim());
|
|
||||||
|
|
||||||
if (!orderGroupId) {
|
|
||||||
return jsonResponse({ ok: false, error: "orderGroupId is required" }, 400, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAllowedDeliveryDate(deliveryDate)) {
|
|
||||||
return jsonResponse({ ok: false, error: "Выберите будущий будний день доставки" }, 400, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ALLOWED_DELIVERY_TIMES.has(deliveryTime)) {
|
|
||||||
return jsonResponse({ ok: false, error: "Выберите первую или вторую половину дня доставки" }, 400, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
const accessToken = getBearerToken(request);
|
|
||||||
if (!accessToken) {
|
|
||||||
return jsonResponse({ ok: false, error: "Authentication is required" }, 401, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabase = createServiceClient();
|
|
||||||
const actor = await getUserRole(supabase, accessToken);
|
|
||||||
|
|
||||||
if (!actor || !ALLOWED_ROLES.has(actor.role)) {
|
|
||||||
return jsonResponse({ ok: false, error: "Forbidden" }, 403, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ipHash = await hashText(getClientIp(request));
|
|
||||||
await requireRateLimit(supabase, {
|
|
||||||
scope: "order-group-manual-delivery-choice",
|
|
||||||
key: `${actor.userId}:${ipHash}:${orderGroupId}`,
|
|
||||||
maxCount: 20,
|
|
||||||
windowSeconds: 600,
|
|
||||||
blockSeconds: 1800,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: currentGroup, error: currentGroupError } = await supabase
|
|
||||||
.from("order_groups")
|
|
||||||
.select("id, delivery_status, delivery_invitation_id")
|
|
||||||
.eq("id", orderGroupId)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (currentGroupError) {
|
|
||||||
throw currentGroupError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: group, error: groupUpdateError } = await supabase
|
|
||||||
.from("order_groups")
|
|
||||||
.update({
|
|
||||||
delivery_status: "agreed",
|
|
||||||
delivery_date: deliveryDate,
|
|
||||||
delivery_time: deliveryTime,
|
|
||||||
notification_status: "confirmed",
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
.eq("id", orderGroupId)
|
|
||||||
.select("*")
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (groupUpdateError) {
|
|
||||||
throw groupUpdateError;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentGroup.delivery_invitation_id) {
|
|
||||||
const { error: invitationUpdateError } = await supabase
|
|
||||||
.from("delivery_invitations")
|
|
||||||
.update({
|
|
||||||
state: "agreed",
|
|
||||||
delivery_date: deliveryDate,
|
|
||||||
delivery_time: deliveryTime,
|
|
||||||
confirmed_at: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
.eq("id", currentGroup.delivery_invitation_id);
|
|
||||||
|
|
||||||
if (invitationUpdateError) {
|
|
||||||
throw invitationUpdateError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await insertIntegrationEvent(supabase, {
|
|
||||||
order_id: null,
|
|
||||||
event_type: "order_group_manual_delivery_choice",
|
|
||||||
direction: "internal",
|
|
||||||
status: "success",
|
|
||||||
payload: {
|
|
||||||
order_group_id: orderGroupId,
|
|
||||||
actor_user_id: actor.userId,
|
|
||||||
actor_role: actor.role,
|
|
||||||
old_delivery_status: currentGroup.delivery_status || null,
|
|
||||||
new_delivery_status: "agreed",
|
|
||||||
delivery_date: deliveryDate,
|
|
||||||
delivery_time: deliveryTime,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return jsonResponse(
|
|
||||||
{
|
|
||||||
ok: true,
|
|
||||||
orderGroup: group,
|
|
||||||
},
|
|
||||||
200,
|
|
||||||
corsHeaders,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error && "status" in error) {
|
|
||||||
const httpError = error as { status: number; message: string };
|
|
||||||
return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse(
|
|
||||||
{
|
|
||||||
ok: false,
|
|
||||||
error: error instanceof Error ? error.message : "Unexpected error",
|
|
||||||
},
|
|
||||||
500,
|
|
||||||
corsHeaders,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,190 +0,0 @@
|
||||||
import { createServiceClient } from "../_shared/security.ts";
|
|
||||||
import {
|
|
||||||
getClientIp,
|
|
||||||
getCorsHeaders,
|
|
||||||
hashText,
|
|
||||||
jsonResponse,
|
|
||||||
preflightResponse,
|
|
||||||
readJsonBody,
|
|
||||||
requireRateLimit,
|
|
||||||
} from "../_shared/security.ts";
|
|
||||||
|
|
||||||
const MAX_BODY_BYTES = 8 * 1024;
|
|
||||||
const OTP_EXPIRY_SECONDS = 600; // 10 minutes
|
|
||||||
|
|
||||||
const isValidEmail = (value: string) =>
|
|
||||||
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
|
|
||||||
|
|
||||||
Deno.serve(async (request) => {
|
|
||||||
if (request.method === "OPTIONS") {
|
|
||||||
return preflightResponse(request, "public");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.method !== "POST") {
|
|
||||||
return jsonResponse({ ok: false, error: "Method not allowed" }, 405);
|
|
||||||
}
|
|
||||||
|
|
||||||
const corsHeaders = getCorsHeaders(request, "public");
|
|
||||||
if (!corsHeaders) {
|
|
||||||
return jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { body } = await readJsonBody<{ email?: string; otp?: string }>(request, {
|
|
||||||
maxBytes: MAX_BODY_BYTES,
|
|
||||||
});
|
|
||||||
const email = String(body.email || "").trim().toLowerCase();
|
|
||||||
const otp = String(body.otp || "").trim();
|
|
||||||
|
|
||||||
if (!email || !isValidEmail(email)) {
|
|
||||||
return jsonResponse({ ok: false, error: "Valid email is required" }, 400, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!otp || otp.length < 4 || otp.length > 12) {
|
|
||||||
return jsonResponse({ ok: false, error: "Valid OTP is required" }, 400, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabase = createServiceClient();
|
|
||||||
const emailHash = await hashText(email);
|
|
||||||
const ipHash = await hashText(getClientIp(request));
|
|
||||||
|
|
||||||
await requireRateLimit(supabase, {
|
|
||||||
scope: "otp-verify",
|
|
||||||
key: `${ipHash}:${emailHash}`,
|
|
||||||
maxCount: 5,
|
|
||||||
windowSeconds: 600,
|
|
||||||
blockSeconds: 1800,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 1. Find the most recent unverified OTP for this email
|
|
||||||
const { data: otpRecords, error: fetchError } = await supabase
|
|
||||||
.from("login_otps")
|
|
||||||
.select("*")
|
|
||||||
.eq("email", email)
|
|
||||||
.eq("verified", false)
|
|
||||||
.order("created_at", { ascending: false })
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (fetchError || !otpRecords || otpRecords.length === 0) {
|
|
||||||
return jsonResponse({ ok: false, error: "Неверный или просроченный код" }, 400, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
const otpRecord = otpRecords[0];
|
|
||||||
|
|
||||||
// 2. Check expiry (10 minutes)
|
|
||||||
const createdAt = new Date(otpRecord.created_at);
|
|
||||||
const now = new Date();
|
|
||||||
const elapsedSeconds = (now.getTime() - createdAt.getTime()) / 1000;
|
|
||||||
|
|
||||||
if (elapsedSeconds > OTP_EXPIRY_SECONDS) {
|
|
||||||
await supabase.from("login_otps").delete().eq("id", otpRecord.id);
|
|
||||||
return jsonResponse({ ok: false, error: "Код истёк. Запросите новый." }, 400, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Verify OTP — compare hash (new) with fallback to plaintext (old records)
|
|
||||||
const submittedOtpHash = await hashText(otp);
|
|
||||||
let otpMatches = false;
|
|
||||||
|
|
||||||
if (otpRecord.otp_code_hash) {
|
|
||||||
// New flow: compare SHA-256 hashes
|
|
||||||
otpMatches = otpRecord.otp_code_hash === submittedOtpHash;
|
|
||||||
} else if (otpRecord.otp_code) {
|
|
||||||
// Legacy fallback: plaintext comparison for old records
|
|
||||||
otpMatches = otpRecord.otp_code === otp;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!otpMatches) {
|
|
||||||
return jsonResponse({ ok: false, error: "Неверный код" }, 400, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Mark as verified and clear plaintext if present
|
|
||||||
await supabase
|
|
||||||
.from("login_otps")
|
|
||||||
.update({ verified: true, otp_code: "" })
|
|
||||||
.eq("id", otpRecord.id);
|
|
||||||
|
|
||||||
// Delete all other unverified OTPs for this email
|
|
||||||
await supabase
|
|
||||||
.from("login_otps")
|
|
||||||
.delete()
|
|
||||||
.eq("email", email)
|
|
||||||
.eq("verified", false);
|
|
||||||
|
|
||||||
// 5. Find user by email to get user_id
|
|
||||||
const { data: users } = await supabase
|
|
||||||
.from("users")
|
|
||||||
.select("id, name, roles(name)")
|
|
||||||
.eq("email", email)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!users || users.length === 0) {
|
|
||||||
return jsonResponse({ ok: false, error: "Пользователь не найден" }, 400, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = users[0].id;
|
|
||||||
const userName = users[0].name || null;
|
|
||||||
const userRole = users[0].roles?.name || null;
|
|
||||||
|
|
||||||
// Update the login_otps record with user info
|
|
||||||
await supabase
|
|
||||||
.from("login_otps")
|
|
||||||
.update({ name: userName, role: userRole })
|
|
||||||
.eq("id", otpRecord.id);
|
|
||||||
|
|
||||||
// 6. Create session using Supabase admin API
|
|
||||||
const { data: linkData, error: linkError } = await supabase.auth.admin.generateLink({
|
|
||||||
type: "magiclink",
|
|
||||||
email,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (linkError || !linkData) {
|
|
||||||
console.error("generateLink error:", linkError);
|
|
||||||
return jsonResponse({ ok: false, error: "Ошибка авторизации" }, 500, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
const generatedLink = linkData as any;
|
|
||||||
const tokenHash = generatedLink.properties?.hashed_token || generatedLink.properties?.token_hash;
|
|
||||||
|
|
||||||
if (!tokenHash) {
|
|
||||||
console.error("No token in generateLink response");
|
|
||||||
return jsonResponse({ ok: false, error: "Ошибка авторизации" }, 500, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: verifyData, error: verifyError } = await supabase.auth.verifyOtp({
|
|
||||||
type: "magiclink",
|
|
||||||
token_hash: tokenHash,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (verifyError) {
|
|
||||||
console.error("verifyOtp error:", verifyError);
|
|
||||||
return jsonResponse({ ok: false, error: "Ошибка авторизации" }, 500, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = verifyData.session;
|
|
||||||
const user = verifyData.user;
|
|
||||||
|
|
||||||
return jsonResponse(
|
|
||||||
{
|
|
||||||
ok: true,
|
|
||||||
session: session || null,
|
|
||||||
user: user || null,
|
|
||||||
},
|
|
||||||
200,
|
|
||||||
corsHeaders,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error && "status" in error) {
|
|
||||||
const httpError = error as { status: number; message: string };
|
|
||||||
return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse(
|
|
||||||
{
|
|
||||||
ok: false,
|
|
||||||
error: error instanceof Error ? error.message : "Unexpected error",
|
|
||||||
},
|
|
||||||
500,
|
|
||||||
corsHeaders,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""Simple webhook listener for Gitea push events to trigger supersam deploy."""
|
|
||||||
import json
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import subprocess
|
|
||||||
import logging
|
|
||||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
||||||
|
|
||||||
SECRET = '1032ef91aacd726907bb72c023813c6827f56354902cc7b30e72626690c6d51d'
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
|
|
||||||
logger = logging.getLogger('webhook')
|
|
||||||
|
|
||||||
class WebhookHandler(BaseHTTPRequestHandler):
|
|
||||||
def verify_signature(self, body):
|
|
||||||
signature = self.headers.get('X-Gitea-Signature', '')
|
|
||||||
if not signature:
|
|
||||||
return False
|
|
||||||
computed = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
|
|
||||||
return hmac.compare_digest(signature, computed)
|
|
||||||
|
|
||||||
def do_POST(self):
|
|
||||||
if self.path == '/webhook/supersam':
|
|
||||||
content_length = int(self.headers.get('Content-Length', 0))
|
|
||||||
body = self.rfile.read(content_length) if content_length else b''
|
|
||||||
if not self.verify_signature(body):
|
|
||||||
self.send_response(403)
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(b'{"error": "invalid signature"}')
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
payload = json.loads(body) if body else {}
|
|
||||||
ref = payload.get('ref', '')
|
|
||||||
if ref == 'refs/heads/main':
|
|
||||||
logger.info('Push to main detected, starting deploy...')
|
|
||||||
result = subprocess.run(
|
|
||||||
['/opt/supersam/deploy.sh'],
|
|
||||||
capture_output=True, text=True, timeout=600
|
|
||||||
)
|
|
||||||
logger.info('Deploy exit code: %s', result.returncode)
|
|
||||||
if result.returncode != 0:
|
|
||||||
logger.error('Deploy stderr: %s', result.stderr[-2000:])
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header('Content-Type', 'application/json')
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(json.dumps({'status': 'deployed', 'exit_code': result.returncode}).encode())
|
|
||||||
else:
|
|
||||||
logger.info('Ignoring push to %s', ref)
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header('Content-Type', 'application/json')
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(b'{"status": "ignored"}')
|
|
||||||
except Exception as e:
|
|
||||||
logger.error('Error: %s', e)
|
|
||||||
self.send_response(500)
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(json.dumps({'error': str(e)}).encode())
|
|
||||||
else:
|
|
||||||
self.send_response(404)
|
|
||||||
self.end_headers()
|
|
||||||
|
|
||||||
def do_GET(self):
|
|
||||||
if self.path == '/health':
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header('Content-Type', 'application/json')
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(b'{"status": "ok"}')
|
|
||||||
else:
|
|
||||||
self.send_response(404)
|
|
||||||
self.end_headers()
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
server = HTTPServer(('0.0.0.0', 9000), WebhookHandler)
|
|
||||||
logger.info('Webhook listener on http://0.0.0.0:9000')
|
|
||||||
server.serve_forever()
|
|
||||||
Loading…
Reference in New Issue