Compare commits

..

1 Commits

Author SHA1 Message Date
root c52171c4a8 feat: add skeleton loading states across all pages
- New Loading.jsx component library (Skeleton, SkeletonPanel, SkeletonPage, SkeletonTable, Spinner, LoadingBlock)
- Dashboard: SkeletonTable/SkeletonPage during isLoading
- OrdersTable, LogisticsReadinessBoard, DriverDeliveryPlanner: show skeleton instead of empty state
- ChatTimeline, DeliverySlotsPicker, GroupDetailPage: skeleton while loading
- AdminDashboard, StopWordsPanel, UserManagementPanel, ErrorLogPanel, ActionLogPanel: skeleton during initial load
- NotificationSettings: skeleton for push toggle and notification preferences
- ClientDeliveryPage: skeleton bars instead of text-only loading
2026-06-12 07:45:19 +00:00
79 changed files with 1064 additions and 6698 deletions

1
.gitignore vendored
View File

@ -7,4 +7,3 @@ dist
.worktrees .worktrees
.superpowers .superpowers
.ruff_cache .ruff_cache
volumes/db/data/

View File

@ -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 для оркестрации доставки.

View File

@ -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

View File

@ -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:

View File

@ -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` и т.д.
@ -95,9 +60,3 @@ Edge function `confirm-delivery-choice` передаёт `p_delivery_type`, `p_p
- `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/`

View File

@ -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`.

View File

@ -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)

View File

@ -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 не найден в системе. Обратитесь к администратору.»

View File

@ -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

View File

@ -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"
} }
] ]
} }

View File

@ -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],

View File

@ -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 ( return (
<div className="rounded-[24px] border border-[var(--color-error)] bg-[var(--color-error-soft)] p-4 text-center"> <div
<p className="text-sm text-[var(--color-text)]">Что-то пошло не так</p> style={{
<button display: 'flex',
onClick={this.handleRetry} flexDirection: 'column',
className="mt-2 rounded-xl bg-[var(--color-primary)] px-4 py-2 text-sm font-semibold text-white" alignItems: 'center',
justifyContent: 'center',
padding: '2rem',
textAlign: 'center',
minHeight: '200px',
}}
> >
Попробовать снова <h2 style={{ marginBottom: '0.5rem', color: '#e53e3e' }}>
</button> Something went wrong
</div>
);
}
return (
<div className="flex min-h-[460px] flex-col items-center justify-center p-8 text-center">
<div className="mb-4 text-5xl"></div>
<h2 className="mb-2 text-xl font-semibold text-[var(--color-text)]">
Что-то пошло не так
</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' && (
<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)]">
{this.state.error.message}
</pre>
)}
<div className="flex gap-3">
<button <button
onClick={this.handleRetry} onClick={this.handleRetry}
className="rounded-xl bg-[var(--color-primary)] px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:opacity-90" style={{
padding: '0.5rem 1.25rem',
fontSize: '0.9rem',
fontWeight: 600,
color: '#fff',
backgroundColor: '#3182ce',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
}}
> >
Попробовать снова Try Again
</button> </button>
<button
onClick={this.handleReload}
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>
</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);
} }

View File

@ -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>

View File

@ -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>
); );
}; };

View File

@ -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>

View File

@ -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>
);
};

View File

@ -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>
))} ))}

View File

@ -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>
);
};

View File

@ -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>

View File

@ -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>

View File

@ -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>
);
};

View File

@ -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("");

View File

@ -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">

View File

@ -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 };

View File

@ -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

View File

@ -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("Адрес доставки");
});
}); });

View File

@ -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>
)} )}

View File

@ -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 };

View File

@ -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 = [
"Ожидает согласования доставки", "Ожидает согласования доставки",
"Доставка согласована", "Доставка согласована",
"Назначен водитель", "Назначен водитель",
"Самовывоз",
"Требуется адрес",
"Проблема доставки", "Проблема доставки",
]; ];

View File

@ -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,

View File

@ -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,
}; };

View File

@ -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 };
};

View File

@ -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 };

View File

@ -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} />

View File

@ -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,
pickupTimeSlot,
}) => {
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, deliveryDate: slot?.date || searchDate || invitation?.deliveryDate || undefined,
deliveryTime: slot?.time || invitation?.deliveryTime || 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,78 +335,17 @@ 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 ? (
<>
{/* Tab switcher */}
<div className="flex gap-2 rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] p-1">
<button
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 <DeliverySlotsPicker
slots={slots} slots={slots}
onSelectSlot={handleSlotSelect} onSelectSlot={handleSlotSelect}
@ -448,28 +353,11 @@ export const ClientDeliveryPage = () => {
/> />
) : null} ) : 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}
{isActiveState && !isChoiceSaved ? ( {isActiveState && !isChoiceSaved ? (
<DeliveryChoiceFlow <DeliveryChoiceFlow
invitation={invitation} invitation={invitation}
selectedSlot={effectiveSelectedSlot} selectedSlot={effectiveSelectedSlot}
onConfirmChoice={handleSaveChoice} onConfirmChoice={handleSaveChoice}
deliveryType={activeTab}
/> />
) : !isActiveState && !isChoiceSaved ? ( ) : !isActiveState && !isChoiceSaved ? (
<DeliveryStateNotice state={invitationState} /> <DeliveryStateNotice state={invitationState} />

View File

@ -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") {

View File

@ -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>
);
};

View File

@ -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>

View File

@ -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 (

View File

@ -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 />,

View File

@ -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,
}); });
}; };

View File

@ -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: "Отменено",
}; };

View File

@ -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 updateResult = await client
const updatePayload = { .from("order_groups")
delivery_status: effectiveDeliveryStatus, .update({
delivery_status: "agreed",
delivery_date: deliveryDate, delivery_date: deliveryDate,
delivery_time: deliveryTime, delivery_time: deliveryTime,
delivery_type: deliveryType || "delivery",
delivery_date_source: "manual", delivery_date_source: "manual",
notification_status: "confirmed", notification_status: "confirmed",
updated_at: new Date().toISOString(), 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
.from("order_groups")
.update(updatePayload)
.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) {

View File

@ -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);
}); });
}); });

View File

@ -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);
} }
}, },
}; };

View File

@ -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 || "Ожидает подтверждения";

View File

@ -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";

View File

@ -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}, Первая половина дня`,

View File

@ -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;
} }
const windowStart = new Date(now.getTime() - windowSeconds * 1000); return {
const { data: recent, error } = await supabase "Access-Control-Allow-Origin": "*",
.from(tableName) "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-request-id, x-signature, x-timestamp, x-webhook-secret",
.select('id, count') "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
.eq('scope', scope) "Access-Control-Max-Age": "86400",
.eq('rate_key', key) Vary: "Origin",
.gte('window_start', windowStart.toISOString()); } satisfies Record<string, string>;
}
const isAllowed =
allowedOrigins.length === 0
? false
: allowedOrigins.some((allowedOrigin) => {
if (allowedOrigin === "*") {
return true;
}
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,
});
}
}

View File

@ -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,
}, },
}); });

View File

@ -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' },
})
}
})

View File

@ -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);

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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`
Фиксирует итог доставки, включая успешную доставку и проблемные сценарии.

View File

@ -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;

View File

@ -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();
});
});

View File

@ -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,
};
};

View File

@ -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;
}
};

View File

@ -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,
});
}
}

View File

@ -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: "Отправлено клиенту",
});
});
});

View File

@ -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;
}
};

View File

@ -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,
);
}
});

View File

@ -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,
);
}
});

View File

@ -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,
);
}
});

View File

@ -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,
);
}
});

View File

@ -1,5 +0,0 @@
{
"imports": {
"@supabase/supabase-js": "https://esm.sh/@supabase/supabase-js@2.49.8"
}
}

View File

@ -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' },
})
}
})

View File

@ -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,
);
}
});

View File

@ -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,
);
}
});

View File

@ -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,
);
}
});

View File

@ -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,
);
}
});

View File

@ -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,
);
}
});

View File

@ -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,
);
}
});

View File

@ -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()