Compare commits

...

20 Commits

Author SHA1 Message Date
root 62475c7e48 fix: remove duplicate renderValue declaration 2026-06-12 15:48:50 +00:00
root 2f2dae686c refactor: Phase 3 resilience — ErrorBoundary, split saving states, extract useStopWords
- ErrorBoundary: Russian UI, compact mode for card-level errors, reload button
- ErrorBoundary wraps OrderDetailPanel in GroupDetailPage + DeliverySetDetailPanel
- Split isSavingDeliveryChoice into 3 independent states:
  isSavingDeliveryChoice (delivery tab save)
  isSavingDriverAssignment (driver assign)
  isSavingStatusChange (status change)
- Extract useStopWords hook + matchesStopWord to shared hooks/useStopWords.js
- Remove 3x duplicated matchesStopWord from OrderDetailPanel, DriverShipmentPanel, OrderCompositionPanel
- Remove useStopWords + supabase import from OrderDetailPanel
2026-06-12 15:47:55 +00:00
root 129175fed7 refactor: Phase 2 decomposition — extract CalendarWidget, StatusActionPanel, DriverAssignmentPanel
- CalendarWidget: eliminates duplicated calendar JSX (delivery + pickup variants)
- StatusActionPanel: status action buttons with onConfirmStatus callback
- DriverAssignmentPanel: driver selection with onDriverSelect/onConfirmDriver callbacks
- OrderDetailPanel reduced from 1510 to 1261 lines
- No UI changes — all props passed through, all class names preserved
2026-06-12 15:30:30 +00:00
root 4dde64ff5a refactor: Phase 1 stabilization — hooks ordering, deduplicate utils, extract shared constants
- P0-1: Move all hooks (useState, useCallback, useMemo) before early return to fix Rules of Hooks violation
- P0-3: Replace 7 inline isPickupOrder checks with single computed variable
- P1: Extract getErrorMessage, normalizeNom to shared utils/deliveryUtils.js
- P1: Replace duplicate STATUS_LABELS with DELIVERY_GROUP_STATUS_LABELS import
- P1: Add requires_address to DELIVERY_GROUP_STATUS_LABELS
- Remove duplicate getErrorMessage from useOrderGroups.js and OrderDetailPanel.jsx
- Remove duplicate normalizeNom from OrderDetailPanel.jsx and orderGroupRepository.js
2026-06-12 14:50:56 +00:00
root 1a665b5165 fix: TDZ crash — move agreedTypeMatchesTab after isEditingDate declaration 2026-06-12 13:34:05 +00:00
root 1cab6c886e fix: tab-aware agreed banner — show delivery form when switching tabs, not stale pickup banner 2026-06-12 13:25:29 +00:00
root fb5728ba43 fix: order numbers clickable+copy, expandable sub-accounts, preserve delivery address on pickup switch, opaque calendar, tab-aware date label 2026-06-12 13:19:57 +00:00
root 005d4467bc Apply 7 UI fixes: center badges, hide not-ready, customer address for pickup, compact calendars, confirm modals, hide driver panel for pickup, format dates 2026-06-12 12:49:48 +00:00
root fe2d8c4e9b fix: restore deleted data preparation vars in AdminDashboard (TDZ crash) 2026-06-12 12:21:23 +00:00
root 498faca24d feat: delivery/pickup switching in admin card + address field
- Added delivery address input field in manual agreement form (visible when delivery type)
- Pickup switching clears delivery_address in DB, delivery switching preserves/updates it
- useOrderGroups passes deliveryType, deliveryAddress, pickupDate, pickupTimeSlot to API
- orderGroupRepository: updateOrderGroupDeliveryChoice now handles delivery_type and address
- Pickup orders: address cleared, pickup_date/pickup_time_slot saved
- Delivery orders: address saved, pickup fields cleared
- Updated tests to match new payload structure
2026-06-12 08:23:41 +00:00
root 9aef4d49c0 fix: pickup display — detect from source_orders.ship, show correct labels, hide placeholder address
- orderGroupRepository: detect pickup from source_orders.ship='САМОВЫВОЗ' and address='САМОВЫВОЗ'
- orderGroupRepository: set effectiveDeliveryType='pickup' when source data indicates pickup even if DB says 'delivery'
- orderGroupRepository: clear deliveryAddress when it's just 'САМОВЫВОЗ' placeholder
- OrderDetailPanel: dynamic header 'Карточка группы самовывоза' vs 'Карточка группы доставки'
- OrderDetailPanel: subtitle now includes orderNumbers for visibility
- OrderDetailPanel: label changed from 'Номер счёта' to 'Заказ' with '+N сч.' for sub-bills
- GroupDetailPage: neutral 'Группа не найдена' instead of 'Группа доставки не найдена'
- Added pickup-specific test case
2026-06-12 08:14:20 +00:00
root ec9b28fa6f fix: resolve merge conflict - remove duplicate order declaration in GroupDetailPage 2026-06-12 07:48:20 +00:00
root 55422ec65a 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:46:58 +00:00
root 69a2023ec1 feat: suggestions panel for employees + admin review 2026-06-10 18:24:58 +00:00
root 7e43f9e990 feat: requires_address status + address input for pickup-to-delivery switch
- New status: requires_address (Требуется адрес) in deliveryWorkflow
- ClientDeliveryPage: address input shown when delivery chosen without address on file
- OrderDetailPanel: requires_address status badge + address input for manager
- confirm_delivery_choice_by_token RPC: p_delivery_address param, sets requires_address status
- Edge function: delivery_address passed to RPC
- deliveryInvitationApi: deliveryAddress param
- CHECK constraint: requires_address, address_required added
- PickupSlotsPicker: styled storage conditions info block
2026-06-10 14:52:17 +00:00
root c774c6a362 feat: add pickup analytics — RPC, hook, PickupStatsPanel, dashboard integration 2026-06-10 12:32:51 +00:00
root 14fe89f899 docs: update documentation for pickup (самовывоз) feature and autodeploy 2026-06-10 12:22:56 +00:00
root 3651dbb484 chore: cleanup 2026-06-10 12:12:10 +00:00
root 813852ff45 fix: JSX structure errors + orderGroupRepository syntax 2026-06-10 12:12:04 +00:00
root e05613ac1d feat: add pickup (самовывоз) delivery type
- New status pickup in delivery workflow
- DB: delivery_type, pickup_date, pickup_time_slot columns
- Client page: tabs Доставка/Самовывоз with PickupSlotsPicker
- PickupSlotsPicker: today/tomorrow/day-after with half-day slots
- Storage notice: free 2 workdays, then 300₽/day
- OrderDetailPanel: delivery type tabs, pickup date/time, status button
- Edge function: delivery_type/pickup fields in confirm-delivery-choice
- RPC: confirm_delivery_choice_by_token updated for pickup
- orderGroupRepository: full pickup field mapping
2026-06-10 12:02:46 +00:00
88 changed files with 7080 additions and 1081 deletions

1
.gitignore vendored
View File

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

View File

@ -9,9 +9,26 @@ npm install
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`
## Главный документ
- [Обзор системы](/Users/mihailkucer/Documents/super-sam/docs/product-overview.md) — назначение приложения, роли, сценарии, клиентский flow и подготовка к показу.
- [Обзор системы](docs/product-overview.md) — назначение приложения, роли, сценарии, клиентский flow и подготовка к показу.
## Что уже есть
@ -19,15 +36,23 @@ npm run dev
- Role-based dashboard для менеджера, логиста и водителя.
- Карточка заказа с составом, комментариями и историей.
- Публичная страница `/delivery/:token` для выбора даты, половины дня и просмотра состава заказа.
- **Самовывоз** — вкладки Доставка/Самовывоз на клиентской странице, выбор даты и половины дня самовывоза, информация о бесплатном хранении (2 рабочих дня) и платном (300₽/день).
- Supabase SQL-схема, таблицы приглашений и Edge Functions для invitation flow.
- Документация по продукту, архитектуре и сценариям.
## Структура
- `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/functions/` — Edge Functions для приглашений, статусов и чат-коммуникаций.
- `supabase/seed/stage-1-demo.sql` — набор seed-данных для показа заказчику.
- `docs/architecture.md` — архитектура фронтенда и модулей.
- `docs/product-overview.md` — общий обзор продукта, ролей и сценариев.
- `docs/scenarios.md` — сценарии жизненного цикла заказа.
- `docs/n8n-order-group-delivery-flow.md` — потоки n8n для оркестрации доставки.

View File

@ -1,30 +1,6 @@
#!/bin/bash
set -e
cd /opt/supersam
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
git pull origin main
docker compose -f docker-compose.app.yml up -d --build
# 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
echo 'Deploy completed at' $(date)

View File

@ -455,6 +455,7 @@ services:
SUPABASE_PUBLISHABLE_KEYS: "{\"default\":\"${SUPABASE_PUBLISHABLE_KEY:-}\"}"
SUPABASE_SECRET_KEYS: "{\"default\":\"${SUPABASE_SECRET_KEY:-}\"}"
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.
VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}"
command:

View File

@ -6,17 +6,25 @@
- `src/context/ThemeContext.jsx` — управление светлой и тёмной темой через `data-theme`.
- `src/hooks/usePwaStatus.js` — клиентское состояние PWA: online/offline, install prompt, standalone и offline readiness.
- `src/hooks/useOrders.js` — локальный state заказов, истории, чатов, фильтров, действий и **сгруппированных наборов доставки** (deliverySetBuckets).
- `src/hooks/useOrderGroups.js` — работа с группами заказов: загрузка, обновление статусов, ручное согласование доставки и самовывоза. Метод `saveManualDeliveryChoice` принимает `deliveryType`, `pickupDate`, `pickupTimeSlot`.
- `src/services/deliverySetViews.js` — чистые функции группировки импортированных заказов в наборы доставки с buckets: «На подходе», «Готово к запуску», «Ожидает клиента», «Нужна ручная работа», «Согласовано», «Завершено».
- `src/services/orderService.js` — чистые функции бизнес-логики заказов, покрытые тестами.
- `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/layouts/AppShell.jsx` — общий shell с боковой навигацией, уведомлениями и переключением темы.
- `src/components/logistics/LogisticsReadinessBoard.jsx` — интерактивная доска с bucket-ами наборов доставки.
- `src/components/logistics/DeliverySetDetailPanel.jsx` — детальная карточка набора доставки: source-поля 1С, production-шаги, слоты, действия.
- `src/components/client/DeliverySlotsPicker.jsx` — публичный виджет выбора даты и половины дня доставки.
- `src/components/client/DeliveryChoiceFlow.jsx` — публичный поток согласования доставки по приглашению.
- `src/components/client/PickupSlotsPicker.jsx` — публичный виджет выбора даты и половины дня самовывоза. Отображает доступные даты (сегодня до 12:00, завтра, послезавтра, без выходных), слоты «До обеда»/«После обеда», и информационный блок о стоимости хранения: «Бесплатное хранение — 2 рабочих дня. С 3-го рабочего дня — 300₽/день.».
- `src/components/client/DeliveryChoiceFlow.jsx` — публичный поток согласования доставки или самовывоза по приглашению. Принимает `deliveryType` и показывает динамические лейблы.
- `src/components/client/DeliveryStateNotice.jsx` — информационный экран для clients со статусом ссылки.
- `src/pages/ClientDeliveryPage.jsx` — публичная страница согласования доставки с вкладками 🚚 Доставка / 🏪 Самовывоз. Переключение типа получения, отображение соответствующего пикера слотов.
- `src/components/orders/*` — фильтры, список заказов, карточка заказа, история статусов и поиск по чату.
- `src/components/orders/OrderDetailPanel.jsx` — карточка заказа с управлением доставкой и самовывозом. Вкладки Доставка/Самовывоз, поля даты самовывоза и половины дня, кнопка статуса «Самовывоз».
- `src/components/orders/OrderEditorPanel.jsx` — создание и редактирование заказа менеджером или администратором.
- `src/components/dashboard/ProductionQueuePanel.jsx` — отдельный блок производственной очереди.
- `src/components/dashboard/RoleWorkspacePanel.jsx` — рабочая панель с delivery-set bucket-ами для логиста.
@ -31,15 +39,42 @@
- **Логист** видит наборы доставки, слоты, сообщения чатбота и ручную обработку исключений.
- **Водитель** видит только назначенные доставки и может переводить их через статусы `Загружен`, `В пути`, `Доставлен`, `Проблема доставки`.
- **Администратор** видит весь массив заказов, доставок и системные логи.
- **Менеджер** может назначить тип доставки (доставка/самовывоз), дату и половину дня.
- Клиент не является авторизованным пользователем приложения. Клиент использует публичную ссылку приглашения.
## Ключевые экраны
- `/login` — email + OTP flow. При отсутствии `VITE_SUPABASE_*` включается demo-режим. Подсказка проверять входящие и спам. Неизвестный email: «Email не найден в системе. Обратитесь к администратору.»
- `/dashboard` — role-based control center: для логиста — LogisticsReadinessBoard с наборами доставки, для водителя — план маршрута и быстрые действия.
- `/delivery/:token` — публичная страница согласования доставки для клиента.
- `/delivery/:token` — публичная страница согласования доставки для клиента с вкладками Доставка/Самовывоз.
- `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С через XML и сохраняются в `public.orders` с source-полями: `source_order_number`, `source_customer_name`, `source_accept_at`, `source_ship_at` и т.д.
@ -60,3 +95,9 @@
- `src/services/safeSupabaseCall.js` стандартизирует обработку ошибок.
- Данные UI разложены по сущностям, совпадающим с таблицами Supabase: `orders`, `order_history`, `chat_messages`, `delivery_slots`, `delivery_invitations`.
- В `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,7 +26,9 @@
- `second_sms_sent_at` - время второй SMS
- `last_sms_error` - текст последней ошибки провайдера
- `next_notification_check_at` - когда `n8n` должен вернуться к записи
- `delivery_date` и `delivery_time` - выбранный слот после подтверждения клиентом
- `delivery_date` и `delivery_time` - выбранный слот доставки после подтверждения клиентом
- `delivery_type` - тип получения: `delivery` (доставка) или `pickup` (самовывоз)
- `pickup_date` и `pickup_time_slot` - выбранный слот самовывоза после подтверждения клиентом
## Окно отправки SMS
@ -66,6 +68,7 @@ public.next_order_group_sms_check_at(start_from timestamptz, delay interval)
- `out_for_delivery` - водитель уже в работе
- `delivered` - доставка завершена
- `cancelled` - группу больше не нужно обрабатывать
- `pickup` - клиент выбрал самовывоз
### `notification_status`
@ -275,6 +278,14 @@ docs/sql/order-groups-auto-delivery-link.sql
Никакой логики 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`.

View File

@ -7,7 +7,7 @@
- показать менеджеру единый реестр доставочных заказов с поиском и карточкой заказа;
- показать логисту список доставок на сегодня и ближайшие дни с половинами дня;
- показать водителю свои доставки, адрес, состав заказа и базовые статусы;
- дать клиенту публичную ссылку, по которой он выбирает дату и половину дня доставки;
- дать клиенту публичную ссылку, по которой он выбирает дату и половину дня доставки **или самовывоза**;
- хранить состояние заказов, приглашений и истории изменений в Supabase.
## Роли
@ -17,6 +17,7 @@
- видит список заказов доставки;
- ищет по номеру заказа, клиенту и телефону;
- открывает карточку заказа и смотрит состав, комментарии и историю;
- может назначить тип доставки (доставка/самовывоз), дату самовывоза и половину дня;
- не работает с созданием заказов и внутренними служебными экранами.
### Логист
@ -24,7 +25,8 @@
- видит заказы, готовые к доставке;
- смотрит ближайшие даты: сегодня, завтра и послезавтра;
- смотрит половину дня и текущий статус доставки;
- открывает карточку заказа, чтобы свериться с деталями.
- открывает карточку заказа, чтобы свериться с деталями;
- может перевести статус заказа в «Самовывоз» и указать дату.
### Водитель
@ -36,9 +38,40 @@
- получает публичную ссылку вида `/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` — «Самовывоз».
## Основные сценарии
### Внутренний сценарий
@ -48,7 +81,7 @@
3. Логист отслеживает готовность и ближайшее окно доставки.
4. Водитель получает свою доставку и доводит её до результата.
### Сценарий клиента
### Сценарий клиента (доставка)
Клиентская страница работает по token из таблицы `public.delivery_invitations`.
@ -59,21 +92,27 @@
Эта ссылка показывает:
- заказ `CD-240031`;
- состав заказа;
- четыре варианта слота;
- две даты;
- две половины дня: `До обеда` и `После обеда`.
- вкладки «Доставка» и «Самовывоз»;
- на вкладке «Доставка» — четыре варианта слота, две даты, две половины дня.
После подтверждения выбора:
- invitation переводится в состояние `agreed`;
- заказ переводится в `Доставка согласована`;
- в `order_history` появляется запись о подтверждении;
- в `delivery_slots` фиксируется подтверждённый слот.
### Сценарий клиента (самовывоз)
При выборе вкладки «Самовывоз»:
- доступны даты начиная с дня готовности (если до 12:00) или завтра;
- две половины дня: «До обеда» и «После обеда»;
- информационный блок о стоимости хранения;
- подтверждение устанавливает `delivery_type = 'pickup'`, `delivery_status = 'pickup'`.
## Что хранится в Supabase
- `public.users` — пользователи и роли;
- `public.orders` — заказы и текущие статусы;
- `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_invitations` — публичные invitation token и состояние клиентского flow;
- `public.integration_events` — технические и интеграционные события.
@ -95,11 +134,13 @@
- реестр заказов и карточку заказа;
- список доставок по датам для логиста;
- карточку доставки водителя;
- клиентскую ссылку с выбором даты и половины дня.
- клиентскую ссылку с выбором типа получения (доставка/самовывоз) и датой;
- информационный блок о стоимости хранения при самовывозе.
## Полезные документы
- [README](/Users/mihailkucer/Documents/super-sam/README.md)
- [Архитектура](/Users/mihailkucer/Documents/super-sam/docs/architecture.md)
- [Сценарии](/Users/mihailkucer/Documents/super-sam/docs/scenarios.md)
- [Edge Functions](/Users/mihailkucer/Documents/super-sam/supabase/functions/README.md)
- [README](../README.md)
- [Архитектура](architecture.md)
- [Сценарии](scenarios.md)
- [Поток n8n](n8n-order-group-delivery-flow.md)
- [Edge Functions](../supabase/functions/README.md)

View File

@ -20,15 +20,16 @@
- Перечнем заказов набора, их 1С-номерами и шагами производства (раскрой, склейка, криволинейные, контроль качества, отгрузка).
- Телефоном и email клиента, городом, связанными счетами.
- Текущим статусом слота.
3. Логист может запустить приглашение, назначить водителя или перейти к ручной обработке.
## 3. Согласование доставки с клиентом
1. Когда набор доставки готов, логист запускает отправку приглашения клиенту.
2. Клиент получает ссылку на `/delivery/:token`.
3. На странице клиент видит **DeliverySlotsPicker** с доступными датами и половинами дня.
4. Клиент выбирает слот и подтверждает. Статус набора переходит в «Ожидает клиента» → «Согласовано».
5. Если клиент не отвечает, система или логист переводит набор в «Нужна ручная работа».
3. На странице клиент видит вкладки **🚚 Доставка** и **🏪 Самовывоз**.
4. На вкладке «Доставка» — **DeliverySlotsPicker** с доступными датами и половинами дня.
5. На вкладке «Самовывоз» — **PickupSlotsPicker** с датами (сегодня до 12:00, завтра, послезавтра, без выходных) и половинами дня, а также информационный блок: «Бесплатное хранение — 2 рабочих дня. С 3-го рабочего дня — 300₽/день.»
6. Клиент выбирает тип получения, слот и подтверждает. Статус набора переходит в «Ожидает клиента» → «Согласовано» или «Самовывоз».
7. Если клиент не отвечает, система или логист переводит набор в «Нужна ручная работа».
## 4. Перенос доставки
@ -55,6 +56,30 @@
2. После закрытия всех заказов набора он переходит в «Завершено».
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. Зайти под логистом.
@ -65,6 +90,6 @@
- Фролова И.Д. — «Нужна ручная работа» (платное хранение).
- Орлова Н.С. — «Завершено».
3. Кликнуть по набору Савина — увидеть source-поля, production-шаги, готовность к запуску.
4. Перейти на публичную страницу приглашения — увидеть `DeliverySlotsPicker` с выбором даты и половины дня.
4. Перейти на публичную страницу приглашения — увидеть вкладки «Доставка» и «Самовывоз» с выбором даты и половины дня.
5. Зайти под водителем — увидеть назначенные доставки с адресами и быстрыми действиями.
6. Зайти под несуществующим email — увидеть «Email не найден в системе. Обратитесь к администратору.»

View File

@ -6,14 +6,15 @@
<meta name="theme-color" content="#12805c" />
<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-title" content="SuperSam" />
<meta
name="description"
content="Демо-панель управления доставкой и заказами с офлайн-доступом после первого запуска."
content="Панель управления доставкой и заказами с офлайн-доступом после первого запуска."
/>
<link rel="icon" type="image/svg+xml" href="/icons/icon-192.svg" />
<link rel="icon" type="image/png" href="/icons/icon-192.png" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Construction Delivery Control</title>
<title>SuperSam Доставка</title>
</head>
<body>
<div id="root"></div>

BIN
public/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
public/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -10,16 +10,28 @@
"lang": "ru",
"icons": [
{
"src": "/icons/icon-192.svg",
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/svg+xml",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.svg",
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/svg+xml",
"type": "image/png",
"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";
if (!isLocalhost) {
const STATIC_CACHE = "construction-delivery-static-v4";
const RUNTIME_CACHE = "construction-delivery-runtime-v4";
const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.svg", "/icons/icon-512.svg"];
const STATIC_CACHE = "construction-delivery-static-v5";
const RUNTIME_CACHE = "construction-delivery-runtime-v5";
const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
self.addEventListener("install", (event) => {
event.waitUntil(
@ -93,8 +93,8 @@ self.addEventListener("push", (event) => {
const title = data.title || "Уведомление";
const options = {
body: data.body || "",
icon: data.icon || "/icons/icon-192.svg",
badge: data.badge || "/icons/icon-192.svg",
icon: data.icon || "/icons/icon-192.png",
badge: data.badge || "/icons/icon-192.png",
data: data.data || {},
tag: data.tag || "default",
vibrate: [100, 50, 100],

View File

@ -12,7 +12,6 @@ class ErrorBoundary extends React.Component {
}
componentDidCatch(error, errorInfo) {
// Extract component stack for richer context
const componentInfo = {
component: errorInfo?.componentStack || null,
props: this.props,
@ -25,47 +24,61 @@ class ErrorBoundary extends React.Component {
this.setState({ hasError: false, error: null });
};
handleReload = () => {
window.location.reload();
};
renderDefaultFallback() {
const { compact } = this.props;
if (compact) {
return (
<div className="rounded-[24px] border border-[var(--color-error)] bg-[var(--color-error-soft)] p-4 text-center">
<p className="text-sm text-[var(--color-text)]">Что-то пошло не так</p>
<button
onClick={this.handleRetry}
className="mt-2 rounded-xl bg-[var(--color-primary)] px-4 py-2 text-sm font-semibold text-white"
>
Попробовать снова
</button>
</div>
);
}
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '2rem',
textAlign: 'center',
minHeight: '200px',
}}
>
<h2 style={{ marginBottom: '0.5rem', color: '#e53e3e' }}>
Something went wrong
<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>
<p style={{ marginBottom: '1rem', color: '#718096', fontSize: '0.9rem' }}>
An unexpected error occurred. You can try again.
<p className="mb-6 max-w-md text-sm text-[var(--color-text-muted)]">
Произошла непредвиденная ошибка. Попробуйте обновить страницу или вернуться позже.
</p>
<button
onClick={this.handleRetry}
style={{
padding: '0.5rem 1.25rem',
fontSize: '0.9rem',
fontWeight: 600,
color: '#fff',
backgroundColor: '#3182ce',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
}}
>
Try Again
</button>
{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
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"
>
Попробовать снова
</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>
);
}
render() {
if (this.state.hasError) {
// Allow custom fallback render function
if (typeof this.props.fallback === 'function') {
return this.props.fallback(this.state.error, this.handleRetry);
}

View File

@ -0,0 +1,166 @@
import React from "react";
import { cn } from "../../lib/cn";
/**
* Skeleton bar a pulsing placeholder that mimics content shape.
*
* @param {'text'|'heading'|'avatar'|'card'|'table-row'} variant - Preset sizes
* @param {string} [className] - Additional CSS classes
* @param {number} [lines=1] - Number of skeleton lines (for text variant)
*/
export const Skeleton = ({ variant = "text", className, lines = 1 }) => {
const variantClasses = {
text: "h-4 rounded-lg",
heading: "h-6 rounded-lg",
avatar: "h-10 w-10 rounded-full",
card: "h-28 rounded-[24px]",
"table-row": "h-14 rounded-[16px]",
};
if (lines > 1) {
return (
<div className={cn("space-y-3", className)}>
{Array.from({ length: lines }).map((_, i) => (
<div
key={i}
className={cn(
"animate-pulse bg-[var(--color-surface-strong)] rounded-lg",
variantClasses[variant] || variantClasses.text,
i === lines - 1 ? "w-3/4" : "w-full",
)}
/>
))}
</div>
);
}
return (
<div
className={cn(
"animate-pulse bg-[var(--color-surface-strong)] rounded-lg",
variantClasses[variant] || variantClasses.text,
className,
)}
/>
);
};
/**
* Skeleton block that mimics a Panel with header + body lines.
*
* @param {number} [lines=4] - Number of body lines
* @param {string} [className]
*/
export const SkeletonPanel = ({ lines = 4, className }) => (
<div
className={cn(
"rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] p-5 shadow-soft space-y-4",
className,
)}
>
<Skeleton variant="heading" className="w-1/3" />
<div className="space-y-2">
{Array.from({ length: lines }).map((_, i) => (
<Skeleton key={i} className={i === lines - 1 ? "w-2/3" : "w-full"} />
))}
</div>
</div>
);
/**
* Full-page skeleton loader with title bar + content panels.
*
* @param {number} [panels=2] - Number of skeleton panels
* @param {string} [className]
*/
export const SkeletonPage = ({ panels = 2, className }) => (
<div className={cn("space-y-6", className)}>
<div className="space-y-2">
<Skeleton variant="heading" className="w-1/4" />
<Skeleton className="w-1/2" />
</div>
{Array.from({ length: panels }).map((_, i) => (
<SkeletonPanel key={i} />
))}
</div>
);
/**
* Skeleton that mimics a table with header + rows.
*
* @param {number} [rows=4] - Number of skeleton rows
* @param {number} [cols=4] - Number of columns
* @param {string} [className]
*/
export const SkeletonTable = ({ rows = 4, cols = 4, className }) => (
<div
className={cn(
"rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-soft overflow-hidden",
className,
)}
>
{/* Header */}
<div className="flex gap-4 bg-[var(--color-surface-strong)] px-5 py-3">
{Array.from({ length: cols }).map((_, i) => (
<Skeleton key={i} className="flex-1 h-4" />
))}
</div>
{/* Rows */}
{Array.from({ length: rows }).map((_, rowIdx) => (
<div
key={rowIdx}
className="flex gap-4 border-t border-[var(--color-border)] px-5 py-4"
>
{Array.from({ length: cols }).map((_, colIdx) => (
<Skeleton key={colIdx} className="flex-1 h-4" />
))}
</div>
))}
</div>
);
/**
* Inline spinner a compact rotating indicator for buttons and small areas.
*
* @param {string} [size='sm'] - 'xs' | 'sm' | 'md' | 'lg'
* @param {string} [className]
*/
export const Spinner = ({ size = "sm", className }) => {
const sizeClasses = {
xs: "h-3 w-3 border-[1.5px]",
sm: "h-4 w-4 border-2",
md: "h-6 w-6 border-2",
lg: "h-8 w-8 border-[3px]",
};
return (
<span
className={cn(
"inline-block animate-spin rounded-full border-[var(--color-border)] border-t-[var(--color-accent)]",
sizeClasses[size] || sizeClasses.sm,
className,
)}
role="status"
aria-label="Загрузка"
/>
);
};
/**
* Centered loading block with spinner + optional label.
*
* @param {string} [label='Загрузка...']
* @param {string} [size] - Spinner size
* @param {string} [className]
*/
export const LoadingBlock = ({ label = "Загрузка...", size = "md", className }) => (
<div
className={cn(
"flex flex-col items-center justify-center gap-3 py-8 text-[var(--color-text-muted)]",
className,
)}
>
<Spinner size={size} />
{label && <span className="text-sm">{label}</span>}
</div>
);

View File

@ -51,7 +51,7 @@ export const PwaInstallButton = ({ onInstall, isInstalled, isInstallAvailable })
</button>
{showTip && (
<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">
<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">
{isIOS ? (
<>
<p className="font-medium">Установка на iOS</p>

View File

@ -1,13 +1,18 @@
import React from "react";
import { Button } from "./Button";
import { useTheme } from "../../context/ThemeContext";
export const ThemeToggle = () => {
const { theme, toggleTheme } = useTheme();
return (
<Button variant="secondary" size="sm" onClick={toggleTheme}>
{theme === "light" ? "Тёмная тема" : "Светлая тема"}
</Button>
<button
type="button"
onClick={toggleTheme}
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

@ -1,6 +1,7 @@
import React, { useState, useEffect, useMemo, useCallback } from "react";
import { Panel } from "../UI/Panel";
import { Badge } from "../UI/Badge";
import { Skeleton } from "../UI/Loading";
import { fetchActionLogs, getActionLabel } from "../../services/supabase/actionLogService";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../../context/AuthContext";
@ -351,7 +352,13 @@ export const ActionLogPanel = ({ orderGroupId = null }) => {
</table>
</div>
{loading && <div className="py-4 text-center text-sm text-[var(--color-text-muted)]">Загрузка...</div>}
{loading && !filteredLogs.length && (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="w-full h-10" />
))}
</div>
)}
</Panel>
);
};

View File

@ -1,3 +1,9 @@
/**
* @file AdminDashboard.jsx
* @description Admin analytics dashboard. Displays KPI cards, status pie chart,
* daily trend line, confirmation funnel, SMS stats, and driver performance
* bar chart. Supports period selection (1d/7d/30d/all) and mobile layout.
*/
import React, { useState, useEffect } from 'react';
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer,
@ -6,8 +12,12 @@ import {
import { Panel } from '../UI/Panel';
import { Badge } from '../UI/Badge';
import { SegmentedTabs } from '../UI/SegmentedTabs';
import { Skeleton } from '../UI/Loading';
import { useAdminStats } from '../../hooks/useAdminStats';
import { usePickupStats } from '../../hooks/usePickupStats';
import { PickupStatsPanel } from './PickupStatsPanel';
// Mobile Detection Hook
const useIsMobile = () => {
const [mobile, setMobile] = useState(false);
useEffect(() => {
@ -20,6 +30,7 @@ const useIsMobile = () => {
return mobile;
};
// Status Colour & Label Maps
const STATUS_COLORS = {
pending_confirmation: '#94a3b8',
manual_confirmation_required: '#eab308',
@ -31,6 +42,7 @@ const STATUS_COLORS = {
paid_storage: '#06b6d4',
problem: '#ef4444',
cancelled: '#64748b',
pickup: '#f59e0b',
};
const STATUS_LABELS = {
@ -44,8 +56,10 @@ const STATUS_LABELS = {
paid_storage: 'Оплаченное хранение',
problem: 'Проблема',
cancelled: 'Отменено',
pickup: 'Самовывоз',
};
// Period Selector Options
const PERIOD_OPTIONS = [
{ key: '1d', label: 'Сегодня' },
{ key: '7d', label: '7 дней' },
@ -53,6 +67,7 @@ const PERIOD_OPTIONS = [
{ key: 'all', label: 'Все' },
];
// Custom Recharts Tooltip
const CustomTooltip = ({ active, payload, label: tooltipLabel }) => {
if (!active || !payload?.length) return null;
return (
@ -70,16 +85,45 @@ const CustomTooltip = ({ active, payload, label: tooltipLabel }) => {
);
};
// AdminDashboard Component
export const AdminDashboard = () => {
// State & Hooks
const [period, setPeriod] = useState('7d');
const mobile = useIsMobile();
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
if (isLoading) {
return (
<Panel>
<div style={{ textAlign: 'center', padding: '2rem', color: 'var(--color-text-muted)' }}>Загрузка...</div>
</Panel>
<div style={{ display: 'flex', flexDirection: 'column', gap: mobile ? '0.75rem' : '1.25rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', gap: '0.5rem' }}>
<Skeleton variant="heading" className="w-1/4" />
<Skeleton className="w-32 h-8" />
</div>
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr' : `repeat(auto-fit, minmax(80px, 160px))`, gap: '0.4rem' }}>
{Array.from({ length: 6 }).map((_, i) => (
<Panel key={i} style={{ padding: mobile ? '0.4rem 0.6rem' : '0.5rem 0.75rem', textAlign: 'center' }}>
<Skeleton className="w-12 h-3 mb-1" />
<Skeleton className="w-8 h-5" />
</Panel>
))}
</div>
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
<Skeleton variant="heading" className="w-1/3 mb-3" />
<div style={{ height: chartHeight }} className="flex items-center justify-center">
<Skeleton className="w-3/4 h-40" />
</div>
</Panel>
</div>
);
}
if (error) {
@ -90,6 +134,7 @@ export const AdminDashboard = () => {
);
}
// Data Preparation
const sv = stats || {};
const totalGroups = sv.total || 0;
const econ = economics || {};
@ -100,6 +145,7 @@ export const AdminDashboard = () => {
status: s.delivery_status,
})).filter(d => d.value > 0);
// Trend & Driver Data
const trendData = (dailyTrend || []).map(d => ({
date: d.date ? new Date(d.date).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' }) : '',
delivered: d.delivered || 0, total: d.total || 0, problems: d.problems || 0,
@ -111,6 +157,7 @@ export const AdminDashboard = () => {
}));
// Funnel: ALWAYS show all steps, even with 0 values
// Funnel Data
const funnelSteps = [
{ label: 'Согласовано после 1-й SMS', value: econ.confirmed_after_sms1 || 0, color: '#22c55e' },
{ label: 'Согласовано после 2-й SMS', value: econ.confirmed_after_sms2 || 0, color: '#14b8a6' },
@ -120,14 +167,7 @@ export const AdminDashboard = () => {
{ label: 'Отмена', value: econ.cancelled_count || 0, color: '#ef4444' },
];
// Responsive 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
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: mobile ? '0.75rem' : '1.25rem' }}>
@ -309,6 +349,9 @@ export const AdminDashboard = () => {
</div>
</Panel>
{/* Pickup Stats */}
<PickupStatsPanel stats={pickupStats} isLoading={pickupLoading} mobile={mobile} fontSize={fontSize} />
{/* Drivers */}
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.4rem', color: 'var(--color-text)' }}>По водителям</h3>

View File

@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Panel } from '../UI/Panel';
import { Badge } from '../UI/Badge';
import { Select } from '../UI/Select';
import { Skeleton } from '../UI/Loading';
import { supabase } from '../../supabaseClient';
@ -274,7 +275,11 @@ export default function ErrorLogPanel() {
)}
{loading ? (
<div style={{ textAlign: 'center', padding: '2rem', color: 'var(--color-text-muted, #94a3b8)' }}>Загрузка</div>
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="w-full h-12 rounded-lg" />
))}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0' }}>
{errors.length === 0 && (

View File

@ -0,0 +1,167 @@
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

@ -1,14 +1,17 @@
import React from "react";
import { Panel } from "../UI/Panel";
import { Button } from "../UI/Button";
import { Skeleton } from "../UI/Loading";
import { supabase } from "../../supabaseClient";
export const StopWordsPanel = () => {
const [words, setWords] = React.useState([]);
const [scope, setScope] = React.useState("everywhere");
const [newWord, setNewWord] = React.useState("");
const [isLoading, setIsLoading] = React.useState(true);
const [isSavingScope, setIsSavingScope] = React.useState(false);
const [error, setError] = React.useState("");
const [deletingId, setDeletingId] = React.useState(null);
const [savingId, setSavingId] = React.useState(null);
const loadWords = React.useCallback(async () => {
setIsLoading(true);
@ -25,7 +28,18 @@ export const StopWordsPanel = () => {
setIsLoading(false);
}, []);
React.useEffect(() => { loadWords(); }, [loadWords]);
const loadScope = React.useCallback(async () => {
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 trimmed = newWord.trim().toLowerCase();
@ -47,7 +61,7 @@ export const StopWordsPanel = () => {
};
const handleDelete = async (id) => {
setDeletingId(id);
setSavingId(id);
const { error: deleteError } = await supabase
.from("stop_words")
.delete()
@ -57,7 +71,21 @@ export const StopWordsPanel = () => {
} else {
await loadWords();
}
setDeletingId(null);
setSavingId(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) => {
@ -72,8 +100,46 @@ export const StopWordsPanel = () => {
<div>
<h2 className="text-lg font-semibold">Стоп-слова</h2>
<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>
</div>
@ -97,7 +163,11 @@ export const StopWordsPanel = () => {
)}
{isLoading ? (
<p className="text-sm text-[var(--color-text-muted)]">Загрузка...</p>
<div className="flex flex-wrap gap-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="w-20 h-8 rounded-full" />
))}
</div>
) : !words.length ? (
<p className="text-sm text-[var(--color-text-muted)]">Стоп-слов пока нет. Добавьте первое.</p>
) : (
@ -110,12 +180,12 @@ export const StopWordsPanel = () => {
{w.word}
<button
type="button"
disabled={deletingId === w.id}
disabled={savingId === 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"
aria-label={`Удалить ${w.word}`}
>
×
</button>
</span>
))}

View File

@ -0,0 +1,259 @@
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

@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Panel } from '../UI/Panel';
import { Badge } from '../UI/Badge';
import { Input } from '../UI/Input';
import { Skeleton } from '../UI/Loading';
import { supabase, supabaseUrl } from '../../supabaseClient';
@ -274,7 +275,18 @@ export default function UserManagementPanel() {
const fmtDate = (iso) => iso ? new Date(iso).toLocaleString('ru-RU') : '—';
if (loading) {
return <Panel className="p-5"><div className="text-center py-8 text-[var(--color-text-muted)]">Загрузка</div></Panel>;
return (
<Panel className="p-5">
<div className="space-y-3">
<Skeleton variant="heading" className="w-1/4" />
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="w-full h-14 rounded-[22px]" />
))}
</div>
</div>
</Panel>
);
}
return (

View File

@ -1,8 +1,22 @@
import React from "react";
import { formatDateTime } from "../../utils/formatters";
import { Badge } from "../UI/Badge";
import { Skeleton } from "../UI/Loading";
export const ChatTimeline = ({ messages, isLoading = false }) => {
if (isLoading) {
return (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4">
<Skeleton variant="text" className="w-1/4 mb-2" />
<Skeleton variant="text" className="w-full" />
</div>
))}
</div>
);
}
export const ChatTimeline = ({ messages }) => {
if (!messages.length) {
return (
<div className="rounded-[24px] border border-dashed border-[var(--color-border)] p-4 text-sm text-[var(--color-text-muted)]">

View File

@ -22,6 +22,7 @@ export const DeliveryChoiceFlow = ({
invitation = {},
selectedSlot = null,
onConfirmChoice = () => {},
deliveryType = "delivery",
}) => {
const state = invitation.state || "awaiting_choice";
const isActive = ACTIVE_STATES.has(state);
@ -36,16 +37,22 @@ export const DeliveryChoiceFlow = ({
);
}
const typeLabel = deliveryType === "pickup" ? "самовывоз" : "доставку";
return (
<Panel className="space-y-5 p-5 sm:p-6">
<div className="space-y-2">
<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)]">
{deliveryType === "pickup" ? "Согласование самовывоза" : "Согласование доставки"}
</p>
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold leading-tight sm:text-3xl">Выберите время доставки</h1>
<h1 className="text-2xl font-semibold leading-tight sm:text-3xl">
{deliveryType === "pickup" ? "Выберите время самовывоза" : "Выберите время доставки"}
</h1>
<Badge tone="warning">{STATE_LABELS[state]}</Badge>
</div>
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
{invitationReference}. Выберите удобную половину дня.
{invitationReference}. Выберите удобную половину дня для {typeLabel}.
</p>
</div>

View File

@ -1,6 +1,7 @@
import React from "react";
import { Button } from "../UI/Button";
import { Panel } from "../UI/Panel";
import { Skeleton } from "../UI/Loading";
import { formatDeliveryDate, getDeliveryRelativeDayLabel } from "./deliveryDateFormatting";
const groupSlotsByDate = (slots) => {
@ -55,7 +56,20 @@ export const DeliverySlotsPicker = ({
onSelectSlot,
selectedSlotId,
referenceDate = new Date(),
isLoading = false,
}) => {
if (isLoading) {
return (
<Panel className="p-5 sm:p-6">
<div className="space-y-3">
<Skeleton variant="heading" className="w-1/2" />
<Skeleton variant="text" className="w-full" />
<Skeleton variant="text" className="w-3/4" />
</div>
</Panel>
);
}
if (!slots || !slots.length) {
return (
<Panel className="p-5 sm:p-6">

View File

@ -2,6 +2,8 @@ import React from "react";
import { Badge } from "../UI/Badge";
import { Panel } from "../UI/Panel";
import { getInvitationReferenceLabel } from "./invitationReference";
import { supabase } from "../../supabaseClient";
import { matchesStopWord } from "../../hooks/useStopWords";
const flattenOrderProducts = (rawItems) => {
if (!Array.isArray(rawItems) || rawItems.length === 0) return [];
@ -30,6 +32,7 @@ const flattenOrderProducts = (rawItems) => {
name: pName,
quantity: String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(),
unit: String(p.product_ed || p.unit || "").trim(),
_sourceOrder: sub,
});
}
}
@ -42,6 +45,7 @@ const flattenOrderProducts = (rawItems) => {
name: pName,
quantity: String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(),
unit: String(p.product_ed || p.unit || "").trim(),
_sourceOrder: item,
});
}
}
@ -54,23 +58,34 @@ const flattenOrderProducts = (rawItems) => {
name,
quantity: String(item.product_quantity || item.quantity || item.count || item.amount || "").trim(),
unit: String(item.product_ed || item.unit || "").trim(),
_sourceOrder: item,
});
}
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 = {} }) => {
const stopWords = invitation.stopWords || [];
const [stopWords, setStopWords] = React.useState([]);
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 allProducts = flattenOrderProducts(rawItems);
const products = stopWords.length
const products = (stopWords.length && scopeActive)
? allProducts.filter((p) => !matchesStopWord(p.name, stopWords))
: allProducts;
@ -79,6 +94,8 @@ export const OrderCompositionPanel = ({ invitation = {} }) => {
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;
return (
@ -118,11 +135,6 @@ export const OrderCompositionPanel = ({ invitation = {} }) => {
) : null}
</div>
))}
{products.length === 0 && filteredCount > 0 && (
<p className="text-sm text-[var(--color-text-muted)]">
Все позиции исключены из отображения.
</p>
)}
</div>
)}
</Panel>

View File

@ -0,0 +1,212 @@
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

@ -13,6 +13,7 @@ import { Badge } from "../UI/Badge";
import { Input } from "../UI/Input";
import { Select } from "../UI/Select";
import { Panel } from "../UI/Panel";
import { SkeletonPage } from "../UI/Loading";
const CHEVRON_DOWN = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@ -103,7 +104,7 @@ const countByStatus = (items) => {
return result;
};
export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUser }) => {
export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUser, isLoading = false }) => {
const [filters, setFilters] = React.useState({
selectedDate: "",
deliveryStatus: "all",
@ -193,6 +194,10 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
return summary;
}, [groupedOrderGroups]);
if (isLoading) {
return <SkeletonPage panels={3} />;
}
return (
<div className="space-y-4">
<Panel className="space-y-3 p-5">

View File

@ -1,7 +1,9 @@
import React from "react";
import { supabase } from "../../supabaseClient";
import { Badge } from "../UI/Badge";
import { Button } from "../UI/Button";
import { Panel } from "../UI/Panel";
import { matchesStopWord } from "../../hooks/useStopWords";
const parseOrderItems = (order) => {
if (!order) return [];
@ -88,7 +90,25 @@ const parseOrderItems = (order) => {
};
export const DriverShipmentPanel = ({ order, onShipmentChange }) => {
const items = React.useMemo(() => parseOrderItems(order), [order]);
const [stopWords, setStopWords] = React.useState([]);
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 [comments, setComments] = React.useState({});
const [commentInput, setCommentInput] = React.useState("");

View File

@ -1,5 +1,6 @@
import { Button } from "../UI/Button";
import { OrderDetailPanel } from "../orders/OrderDetailPanel";
import ErrorBoundary from "../ErrorBoundary";
export const DeliverySetDetailPanel = ({ deliverySet, onClose }) => {
if (!deliverySet) {
@ -8,7 +9,9 @@ export const DeliverySetDetailPanel = ({ deliverySet, onClose }) => {
return (
<div className="space-y-5">
<OrderDetailPanel order={deliverySet} />
<ErrorBoundary compact>
<OrderDetailPanel order={deliverySet} />
</ErrorBoundary>
{onClose ? (
<div className="flex justify-end">

View File

@ -8,10 +8,11 @@ import {
} from "../../services/orderGroupViews";
import { Badge } from "../UI/Badge";
import { Panel } from "../UI/Panel";
import { SkeletonPage } from "../UI/Loading";
import { OrderFilters } from "../orders/OrderFilters";
import { formatDateTime } from "../../utils/formatters";
export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS }) => {
export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS, isLoading = false }) => {
const [filters, setFilters] = React.useState({ query: "", displayStatus: "all", city: "" });
const [collapsedSections, setCollapsedSections] = React.useState(new Set());
@ -72,6 +73,10 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
</thead>
);
if (isLoading) {
return <SkeletonPage panels={3} />;
}
return (
<div className="space-y-6">
<Panel className="space-y-4 p-5">

View File

@ -3,6 +3,7 @@ import { useNotificationPreferences } from "../../hooks/useNotifications";
import { usePushNotifications } from "../../hooks/usePushNotifications";
import { Panel } from "../UI/Panel";
import { Bell, Settings } from "../UI/Icons";
import { Skeleton } from "../UI/Loading";
const ALL_NOTIF_TYPES = [
{ key: "order_status_change", label: "Изменение статуса", description: "Статус заказа или доставки изменился", roles: ["manager", "logistician", "driver", "admin", "mega_admin"] },
@ -21,6 +22,47 @@ export function NotificationSettings({ userId, userRole, onBack }) {
const visibleTypes = ALL_NOTIF_TYPES.filter((t) => t.roles.includes(role));
const loading = prefsLoading || pushLoading;
if (loading) {
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
{onBack && (
<button
className="rounded p-1 text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
onClick={onBack}
>
Назад
</button>
)}
<h2 className="text-lg font-semibold">Настройки уведомлений</h2>
</div>
<Panel className="p-4">
<div className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="w-32 h-4" />
<Skeleton className="w-48 h-3" />
</div>
<Skeleton className="w-11 h-6 rounded-full" />
</div>
</Panel>
<Panel className="p-4">
<Skeleton className="w-40 h-4 mb-4" />
<div className="space-y-3">
{Array.from({ length: visibleTypes.length || 3 }).map((_, i) => (
<div key={i} className="flex items-start justify-between gap-3">
<div className="space-y-1">
<Skeleton className="w-32 h-4" />
<Skeleton className="w-48 h-3" />
</div>
<Skeleton className="w-11 h-6 rounded-full" />
</div>
))}
</div>
</Panel>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center gap-3">

View File

@ -0,0 +1,185 @@
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

@ -0,0 +1,89 @@
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} />,
);
expect(markup).toContain("Карточка группы доставки");
expect(markup).toContain("Карточка группы");
expect(markup).toContain("Мария Волкова");
expect(markup).toContain("Адрес доставки");
expect(markup).toContain("Симферополь, ул. Ленина, 10");
@ -109,4 +109,22 @@ describe("OrderDetailPanel", () => {
it("skips weekends when selecting the default manual delivery date", () => {
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

@ -1,17 +1,27 @@
import { formatDateTime } from "../../utils/formatters";
import { Badge } from "../UI/Badge";
import { Panel } from "../UI/Panel";
import { SkeletonTable } from "../UI/Loading";
import { OrderFilters } from "./OrderFilters";
import {
getOrderGroupDisplayStatusLabel,
getOrderGroupStatusTone,
} 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 orderCountLabel = `${group.ordersCount || 0} ${group.ordersCount === 1 ? "заказ" : group.ordersCount < 5 ? "заказа" : "заказов"}`;
const parts = [orderCountLabel];
if (group.deliveryDate) {
const datePart = group.deliveryTime ? `${group.deliveryDate} · ${group.deliveryTime}` : group.deliveryDate;
const datePart = group.deliveryTime ? `${fmtDate(group.deliveryDate)} · ${group.deliveryTime}` : fmtDate(group.deliveryDate);
parts.push(datePart);
}
if (group.assignedDriverName) {
@ -22,11 +32,36 @@ const buildGroupSummary = (group) => {
};
const renderOrderNumbers = (group) => {
if (!Array.isArray(group.orderNumbers) || !group.orderNumbers.length) {
const numbers = group.allBillNumbers || group.orderNumbers;
if (!Array.isArray(numbers) || !numbers.length) {
return "Номера не указаны";
}
return group.orderNumbers.slice(0, 3).join(" · ");
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(", ")} +${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 = ({
@ -37,7 +72,12 @@ export const OrdersTable = ({
setFilters,
statusOptions,
cities = [],
isLoading = false,
}) => {
if (isLoading) {
return <SkeletonTable rows={5} cols={5} />;
}
return (
<Panel className="p-0">
<div className="space-y-4 border-b border-[var(--color-border)] px-5 py-4">
@ -87,7 +127,7 @@ export const OrdersTable = ({
</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)]">{renderOrderNumbers(group)}</div>
<div className="mt-2 text-sm text-[var(--color-text-muted)]">{renderMobileOrderNumbers(group)}</div>
<div className="mt-3 text-xs text-[var(--color-text-muted)]">
{formatDateTime(group.updatedAt)}
</div>
@ -104,9 +144,8 @@ export const OrdersTable = ({
<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)]">
<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>
@ -125,18 +164,15 @@ export const OrdersTable = ({
>
<td className="px-5 py-4">
<div className="font-medium">{group.displayTitle || group.customerName || group.groupKey}</div>
<div className="mt-1 text-sm text-[var(--color-text-muted)]">{group.groupKey}</div>
</td>
<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 className="mt-1 text-sm text-[var(--color-text-muted)]">
{[group.customerName, group.customerPhone].filter(Boolean).join(" · ")}
</div>
<div className="text-xs text-[var(--color-text-muted)]">{group.groupKey}</div>
</td>
<td className="max-w-[340px] px-5 py-4 text-sm text-[var(--color-text-muted)]">
<td className="max-w-[260px] px-5 py-4 text-sm text-[var(--color-text-muted)]">
{renderOrderNumbers(group)}
</td>
<td className="px-5 py-4">
<td className="px-5 py-4 text-center">
<Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupDisplayStatusLabel(group)}</Badge>
</td>
<td className="px-5 py-4 text-sm">
@ -144,7 +180,7 @@ export const OrdersTable = ({
</td>
<td className="px-5 py-4 text-sm">
{group.deliveryDate ? (
<span>{group.deliveryDate}{group.deliveryTime ? <span className="text-[var(--color-text-muted)]"> · {group.deliveryTime}</span> : ""}</span>
<span>{fmtDate(group.deliveryDate)}{group.deliveryTime ? <span className="text-[var(--color-text-muted)]"> · {group.deliveryTime}</span> : ""}</span>
) : (
<span className="text-[var(--color-text-muted)]"></span>
)}

View File

@ -0,0 +1,68 @@
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,6 +100,24 @@ export const ORDER_STATUS_META = {
criticalAfterHours: 24,
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: "Автоматическое согласование не завершилось, заказ передан логисту на ручную обработку.",
ownerRole: "logistician",
@ -219,8 +237,8 @@ export const ORDER_STATUS_TRANSITIONS = {
"В производстве": ["Готов к отгрузке", "Требует уточнения", "Отменён"],
"Готов к отгрузке": ["Ожидает согласования доставки", "Ожидает ответа клиента", "Проблема доставки", "Отменён"],
"Ожидает ответа клиента": ["Доставка согласована", "Передан логисту", "Платное хранение", "Проблема доставки", "Отменён"],
"Ожидает согласования доставки": ["Доставка согласована", "Проблема доставки", "Отменён"],
"Доставка согласована": ["Назначен водитель", "Ожидает согласования доставки", "Проблема доставки"],
"Ожидает согласования доставки": ["Доставка согласована", "Самовывоз", "Требуется адрес", "Проблема доставки", "Отменён"],
"Доставка согласована": ["Назначен водитель", "Ожидает согласования доставки", "Проблема доставки", "Самовывоз", "Требуется адрес"],
"Передан логисту": ["Доставка согласована", "Платное хранение", "Проблема доставки", "Отменён"],
"Назначен водитель": ["Загружен", "Проблема доставки"],
Загружен: ["Доставлен", "Проблема доставки"],
@ -228,12 +246,14 @@ export const ORDER_STATUS_TRANSITIONS = {
Доставлен: ["Закрыт"],
"Проблема доставки": ["Ожидает согласования доставки", "Назначен водитель", "Отменён", "Закрыт"],
"Платное хранение": ["Доставка согласована", "Отменён", "Закрыт"],
"Самовывоз": ["Доставка согласована", "Закрыт", "Отменён", "Платное хранение"],
"Требуется адрес": ["Доставка согласована", "Самовывоз", "Отменён", "Проблема доставки"],
Закрыт: [],
Отменён: [],
};
export const ROLE_TRANSITION_TARGETS = {
manager: ORDER_STATUSES,
manager: [...ORDER_STATUSES],
production_lead: ["В очереди производства", "В производстве", "Готов к отгрузке", "Требует уточнения", "Отменён"],
logistician: [
"Новый",
@ -243,6 +263,8 @@ export const ROLE_TRANSITION_TARGETS = {
"Доставка согласована",
"Передан логисту",
"Назначен водитель",
"Самовывоз",
"Требуется адрес",
"Проблема доставки",
"Платное хранение",
"Закрыт",
@ -264,6 +286,8 @@ 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 */
const clearAllAuthStorage = () => {
// Clear Supabase secureStorage keys from sessionStorage
sessionStorage.removeItem("supersam-auth");
sessionStorage.removeItem("supersam-ak");
// Clear Supabase secureStorage keys from localStorage
localStorage.removeItem("supersam-auth");
localStorage.removeItem("supersam-ak");
// Clear local auth cache from localStorage
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem("construction-auth-role-hint");
@ -148,6 +148,8 @@ export const AuthProvider = ({ children }) => {
const [isOtpSent, setIsOtpSent] = useState(false);
const [isLoading, setIsLoading] = useState(false);
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
const signedOutRef = useRef(false);
@ -157,18 +159,31 @@ export const AuthProvider = ({ children }) => {
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 {
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 (!getSessionResolved && event === "INITIAL_SESSION") {
// Don't set user=null or isSessionLoading=false yet let getSession() decide.
return;
}
setUser(null);
setAuthError("");
window.__supersam_user_id__ = null;
setIsSessionLoading(false);
return;
}
// Block session restore if user explicitly signed out (ref or sessionStorage flag)
if (signedOutRef.current || isSignedOut()) {
setIsSessionLoading(false);
return;
}
@ -182,24 +197,29 @@ export const AuthProvider = ({ children }) => {
} else {
setUser({ ...baseUser, role: baseUser.role || "manager" });
}
setIsSessionLoading(false);
});
} else {
setUser(null);
setIsSessionLoading(false);
}
setAuthError("");
});
supabase.auth.getSession().then(({ data, error }) => {
getSessionResolved = true;
if (error && isStaleRefreshTokenError(error)) {
setUser(null);
setAuthError("Сессия истекла. Войдите заново.");
clearAllAuthStorage();
void supabase.auth.signOut({ scope: "local" });
setIsSessionLoading(false);
return;
}
// Block session restore if user explicitly signed out (ref or sessionStorage flag)
if (signedOutRef.current || isSignedOut()) {
setIsSessionLoading(false);
return;
}
@ -212,9 +232,17 @@ export const AuthProvider = ({ children }) => {
} else {
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();
@ -366,6 +394,7 @@ export const AuthProvider = ({ children }) => {
pendingEmail,
isOtpSent,
isLoading,
isSessionLoading,
authError,
isDemoMode,
requestOtp,

View File

@ -7,22 +7,7 @@ import {
ORDER_GROUP_DISPLAY_STATUS_OPTIONS,
getOrderGroupDisplayStatusValue,
} from "../services/orderGroupViews";
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;
};
import { getErrorMessage } from "../utils/deliveryUtils";
export const useOrderGroups = () => {
const [orderGroups, setOrderGroups] = React.useState(() => []);
@ -34,6 +19,8 @@ export const useOrderGroups = () => {
const [isLoading, setIsLoading] = React.useState(true);
const [loadError, setLoadError] = React.useState("");
const [isSavingDeliveryChoice, setIsSavingDeliveryChoice] = React.useState(false);
const [isSavingDriverAssignment, setIsSavingDriverAssignment] = React.useState(false);
const [isSavingStatusChange, setIsSavingStatusChange] = React.useState(false);
React.useEffect(() => {
let cancelled = false;
@ -115,6 +102,10 @@ export const useOrderGroups = () => {
orderGroupId,
deliveryDate,
deliveryTime,
deliveryType,
deliveryAddress,
pickupDate,
pickupTimeSlot,
}) => {
setIsSavingDeliveryChoice(true);
@ -125,6 +116,10 @@ export const useOrderGroups = () => {
orderGroupId,
deliveryDate,
deliveryTime,
deliveryType,
deliveryAddress,
pickupDate,
pickupTimeSlot,
});
if (result.error) {
@ -149,7 +144,7 @@ export const useOrderGroups = () => {
}, []);
const assignDriver = React.useCallback(async ({ orderGroupId, driverId }) => {
setIsSavingDeliveryChoice(true);
setIsSavingDriverAssignment(true);
try {
const result = await assignDriverToOrderGroup({ orderGroupId, driverId });
@ -172,12 +167,12 @@ export const useOrderGroups = () => {
error: getErrorMessage(error, "Не удалось назначить водителя"),
};
} finally {
setIsSavingDeliveryChoice(false);
setIsSavingDriverAssignment(false);
}
}, []);
const changeDeliveryStatus = React.useCallback(async ({ orderGroupId, status, details }) => {
setIsSavingDeliveryChoice(true);
setIsSavingStatusChange(true);
try {
const result = await updateDeliveryStatus({ orderGroupId, status, details });
if (result.error) {
@ -196,7 +191,7 @@ export const useOrderGroups = () => {
error: getErrorMessage(error, "Не удалось обновить статус"),
};
} finally {
setIsSavingDeliveryChoice(false);
setIsSavingStatusChange(false);
}
}, []);
@ -217,6 +212,8 @@ export const useOrderGroups = () => {
assignDriver,
changeDeliveryStatus,
isSavingDeliveryChoice,
isSavingDriverAssignment,
isSavingStatusChange,
isLoading,
loadError,
};

View File

@ -0,0 +1,31 @@
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 };
};

33
src/hooks/useStopWords.js Normal file
View File

@ -0,0 +1,33 @@
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}
</p>
</div>
<div className="flex flex-wrap items-center justify-end gap-2 md:flex-shrink-0">
<div className="flex items-center gap-1 md:flex-shrink-0">
<NotificationBell
notifications={notifications}
unreadCount={unreadCount}
@ -112,7 +112,7 @@ export const AppShell = ({
/>
{onOpenGuide ? (
<Button size="sm" variant="ghost" onClick={onOpenGuide} aria-label="Справка">
{isGuideOpen ? "Назад" : "?"}
?
</Button>
) : null}
<PwaInstallButton onInstall={onInstallApp} isInstalled={isInstalled} isInstallAvailable={isInstallAvailable} />

View File

@ -2,10 +2,12 @@ import React from "react";
import { useParams } from "react-router-dom";
import { DeliveryChoiceFlow } from "../components/client/DeliveryChoiceFlow";
import { DeliverySlotsPicker } from "../components/client/DeliverySlotsPicker";
import { PickupSlotsPicker } from "../components/client/PickupSlotsPicker";
import { OrderCompositionPanel } from "../components/client/OrderCompositionPanel";
import { getInvitationReferenceLabel } from "../components/client/invitationReference";
import { DeliveryStateNotice } from "../components/client/DeliveryStateNotice";
import { Panel } from "../components/UI/Panel";
import { Skeleton } from "../components/UI/Loading";
import { formatDeliveryDate } from "../components/client/deliveryDateFormatting";
import {
confirmDeliveryChoice,
@ -130,10 +132,26 @@ export const buildDeliveryConfirmationPayload = ({
slot,
invitation,
searchDate,
}) => ({
deliveryDate: slot?.date || searchDate || invitation?.deliveryDate || undefined,
deliveryTime: slot?.time || invitation?.deliveryTime || undefined,
});
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,
deliveryTime: slot?.time || invitation?.deliveryTime || undefined,
};
};
export const buildSelectedSlotFromInvitation = (invitation, slots = []) => {
if (!invitation?.deliveryDate) {
@ -163,6 +181,9 @@ export const getClientDeliveryHeroDescription = (isActiveState, isChoiceSaved) =
: "По этому заказу согласование доставки завершено или передано логисту.";
};
const TAB_DELIVERY = "delivery";
const TAB_PICKUP = "pickup";
export const ClientDeliveryPage = () => {
const { token } = useParams();
const [invitation, setInvitation] = React.useState(null);
@ -172,6 +193,8 @@ export const ClientDeliveryPage = () => {
const [selectedSlotId, setSelectedSlotId] = React.useState(null);
const [selectedSlot, setSelectedSlot] = React.useState(null);
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]);
React.useEffect(() => {
@ -195,6 +218,10 @@ export const ClientDeliveryPage = () => {
const loadedInvitation = await fetchDeliveryInvitation(token);
if (!cancelled) {
setInvitation(loadedInvitation);
// If invitation already has deliveryType=pickup, pre-select pickup tab
if (loadedInvitation?.deliveryType === "pickup") {
setActiveTab(TAB_PICKUP);
}
}
} catch (fetchError) {
if (!cancelled) {
@ -248,6 +275,14 @@ export const ClientDeliveryPage = () => {
token,
deliveryTime: effectiveSelectedSlot.time,
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);
setInvitation(loadedInvitation);
@ -282,7 +317,18 @@ export const ClientDeliveryPage = () => {
<Panel className="space-y-3 p-5 sm:p-6">
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Доставка заказа</p>
<h1 className="text-2xl font-semibold leading-tight sm:text-3xl">Загрузка страницы</h1>
<p className="text-sm leading-6 text-[var(--color-text-muted)]">Подтягиваем актуальные данные по заказу.</p>
<div className="space-y-2 mt-4">
<Skeleton className="w-full" />
<Skeleton className="w-3/4" />
<Skeleton className="w-1/2" />
</div>
</Panel>
<Panel className="p-5 sm:p-6">
<div className="space-y-3">
<Skeleton variant="heading" className="w-2/3" />
<Skeleton className="w-full" />
<Skeleton className="w-1/2" />
</div>
</Panel>
</div>
</main>
@ -323,22 +369,99 @@ export const ClientDeliveryPage = () => {
{isChoiceSaved && savedChoiceLabel ? (
<Panel className="space-y-2 p-5 sm:p-6">
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Ваш выбор</p>
<h2 className="text-xl font-semibold leading-tight">Сохранено: {savedChoiceLabel}</h2>
<h2 className="text-xl font-semibold leading-tight">
{invitation?.deliveryType === "pickup" ? "Самовывоз" : "Доставка"}: {savedChoiceLabel}
</h2>
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
{getInvitationReferenceLabel(invitation)}
</p>
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
Статус: доставка уже согласована. При повторном открытии этой ссылки будет показан тот же выбор.
Статус: {invitation?.deliveryType === "pickup" ? "самовывоз" : "доставка"} уже согласован. При повторном открытии этой ссылки будет показан тот же выбор.
</p>
</Panel>
) : null}
{isActiveState && !isChoiceSaved && slots.length ? (
<DeliverySlotsPicker
slots={slots}
onSelectSlot={handleSlotSelect}
selectedSlotId={selectedSlotId}
/>
{isActiveState && !isChoiceSaved ? (
<>
{/* 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
slots={slots}
onSelectSlot={handleSlotSelect}
selectedSlotId={selectedSlotId}
/>
) : null}
{activeTab === TAB_PICKUP ? (
<PickupSlotsPicker
onSelectSlot={handleSlotSelect}
selectedSlotId={selectedSlotId}
referenceDate={referenceDate}
/>
) : null}
{activeTab === TAB_DELIVERY && !slots.length ? (
<Panel className="p-5 sm:p-6">
<p className="text-sm text-[var(--color-text-muted)]">Нет доступных слотов для выбора доставки.</p>
</Panel>
) : null}
</>
) : null}
{isActiveState && !isChoiceSaved ? (
@ -346,6 +469,7 @@ export const ClientDeliveryPage = () => {
invitation={invitation}
selectedSlot={effectiveSelectedSlot}
onConfirmChoice={handleSaveChoice}
deliveryType={activeTab}
/>
) : !isActiveState && !isChoiceSaved ? (
<DeliveryStateNotice state={invitationState} />

View File

@ -1,5 +1,11 @@
/**
* @file DashboardPage.jsx
* @description Main dashboard page. Dispatches to role-specific sections
* (analytics, orders, logistics, driver deliveries, admin panels).
* Manages navigation, notifications, PWA status, and order-group state.
*/
import React from "react";
import { Navigate, useNavigate, useSearchParams } from "react-router-dom";
import { Navigate, useNavigate, useSearchParams, useLocation } from "react-router-dom";
import { DriverDeliveryPlanner } from "../components/driver/DriverDeliveryPlanner";
import { LogisticsReadinessBoard } from "../components/logistics/LogisticsReadinessBoard";
import { OrdersTable } from "../components/orders/OrdersTable";
@ -8,7 +14,9 @@ import UserManagementPanel from "../components/admin/UserManagementPanel";
import ErrorLogPanel from "../components/admin/ErrorLogPanel";
import { StopWordsPanel } from "../components/admin/StopWordsPanel";
import { ActionLogPanel } from "../components/admin/ActionLogPanel";
import { SuggestionsPanel } from "../components/admin/SuggestionsPanel";
import { Panel } from "../components/UI/Panel";
import { SkeletonPage, SkeletonTable } from "../components/UI/Loading";
import { useAuth } from "../context/AuthContext";
import { useNotifications } from "../hooks/useNotifications";
import { usePushNotifications } from "../hooks/usePushNotifications";
@ -16,6 +24,7 @@ import { usePwaStatus } from "../hooks/usePwaStatus";
import { useOrderGroups } from "../hooks/useOrderGroups";
import { AppShell } from "../layouts/AppShell";
// Navigation Config
const MEGA_ADMIN_NAV = [
{ key: "analytics", label: "Аналитика", description: "Статистика доставки, графики и показатели.", badge: null },
{ key: "orders", label: "Группы", description: "Реестр групп доставки.", badge: null },
@ -23,8 +32,10 @@ const MEGA_ADMIN_NAV = [
{ key: "errors", label: "Ошибки", description: "Журнал ошибок приложения.", badge: null },
{ key: "stop_words", label: "Стоп-слова", description: "Слова, исключаемые из клиентской карточки.", badge: null },
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
{ key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null },
];
// Role Default Section Map
const ROLE_SECTION = {
mega_admin: { key: "analytics", label: "Аналитика" },
admin: { key: "analytics", label: "Аналитика", description: "Статистика доставки." },
@ -33,8 +44,10 @@ const ROLE_SECTION = {
driver: { key: "deliveries", label: "Мои доставки", description: "Группы доставки по датам и статусам." },
};
// Dashboard Component
export const DashboardPage = () => {
const { user, signOut } = useAuth();
const { user, signOut, isSessionLoading } = useAuth();
const location = useLocation();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const userRole = user?.role;
@ -52,6 +65,7 @@ export const DashboardPage = () => {
}
};
// Notifications
const {
notifications,
unreadCount,
@ -68,8 +82,10 @@ export const DashboardPage = () => {
}
}, [isSupported, isSubscribed, user?.id, subscribe]);
// PWA
const { isInstalled, isInstallAvailable, installApp: onInstallApp } = usePwaStatus();
// Order Groups
const {
orderGroups,
allOrderGroups,
@ -83,6 +99,7 @@ export const DashboardPage = () => {
loadError,
} = useOrderGroups();
// Derived City List
const cities = React.useMemo(() => {
const set = new Set();
for (const g of allOrderGroups) {
@ -91,6 +108,7 @@ export const DashboardPage = () => {
return [...set].sort();
}, [allOrderGroups]);
// Navigation Builder
const openGroupPage = React.useCallback((groupId) => {
navigate("/dashboard/group/" + groupId);
}, [navigate]);
@ -105,6 +123,7 @@ export const DashboardPage = () => {
{ key: "stop_words", label: "Стоп-слова", description: "Слова, исключаемые из карточки.", badge: null },
{ key: "errors", label: "Ошибки", description: "Журнал ошибок приложения.", badge: null },
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
{ key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null },
]
: userRole === "logistician"
? [
@ -112,15 +131,30 @@ export const DashboardPage = () => {
]
: [
{ 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];
// Auth Guard
const isGuideOpen = false;
if (!user) {
return <Navigate to="/login" replace />;
const ALLOWED_DASHBOARD_ROLES = ["admin", "mega_admin", "manager", "logistician", "driver"];
// Wait for session restore before deciding redirect
if (isSessionLoading) {
return null;
}
if (!user) {
return <Navigate to={`/login?redirect=${encodeURIComponent(location.pathname + location.search)}`} replace />;
}
if (!ALLOWED_DASHBOARD_ROLES.includes(userRole)) {
return <Navigate to="/forbidden" replace />;
}
// Section Renderer
const renderActiveSection = () => {
if (activeSection === "analytics") return <div className="space-y-6 xl:space-y-8"><AdminDashboard /></div>;
@ -128,11 +162,19 @@ export const DashboardPage = () => {
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 === "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 (userRole === "driver") {
return <SkeletonPage panels={3} />;
}
return <SkeletonTable rows={6} cols={5} />;
}
if (userRole === "driver") {
return (
<div className="space-y-6 xl:space-y-8">
<DriverDeliveryPlanner orderGroups={allOrderGroups} onOpenOrder={openGroupPage} currentUser={user} />
<DriverDeliveryPlanner orderGroups={allOrderGroups} onOpenOrder={openGroupPage} currentUser={user} isLoading={isLoading} />
</div>
);
}
@ -146,17 +188,18 @@ export const DashboardPage = () => {
}
return (
<div className="space-y-6 xl:space-y-8">
<LogisticsReadinessBoard orderGroups={allOrderGroups} onSelectSet={openGroupPage} statusOptions={statusOptions} />
<LogisticsReadinessBoard orderGroups={allOrderGroups} onSelectSet={openGroupPage} statusOptions={statusOptions} isLoading={isLoading} />
</div>
);
}
return (
<div className="space-y-6 xl:space-y-8">
<OrdersTable orderGroups={filteredOrderGroups} selectedOrderGroupId={selectedOrderGroupId} onOpenOrder={openGroupPage} filters={filters} setFilters={setFilters} statusOptions={statusOptions} cities={cities} />
<OrdersTable orderGroups={filteredOrderGroups} selectedOrderGroupId={selectedOrderGroupId} onOpenOrder={openGroupPage} filters={filters} setFilters={setFilters} statusOptions={statusOptions} cities={cities} isLoading={isLoading} />
</div>
);
};
// Layout
return (
<AppShell
user={user}
@ -175,11 +218,6 @@ export const DashboardPage = () => {
onMarkNotificationRead={markNotificationRead}
onMarkAllNotificationsRead={markAllNotificationsRead}
>
{isLoading && (
<Panel className="border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
Загружаем данные...
</Panel>
)}
{loadError && (
<Panel className="border border-dashed border-[var(--color-danger)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-danger)]">
Не удалось загрузить данные. Обратитесь к администратору.

View File

@ -0,0 +1,25 @@
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

@ -1,29 +1,44 @@
/**
* @file GroupDetailPage.jsx
* @description Detail view for a single order group. Reads groupId from URL
* params, loads drivers, and renders the OrderDetailPanel.
*/
import React from "react";
import { useNavigate, useParams, useLocation } from "react-router-dom";
import { Navigate, useNavigate, useParams, useLocation } from "react-router-dom";
import { OrderDetailPanel } from "../components/orders/OrderDetailPanel";
import ErrorBoundary from "../components/ErrorBoundary";
import { Button } from "../components/UI/Button";
import { Panel } from "../components/UI/Panel";
import { SkeletonPanel } from "../components/UI/Loading";
import { useAuth } from "../context/AuthContext";
import { fetchDrivers } from "../services/supabase/userRepository";
import { useOrderGroups } from "../hooks/useOrderGroups";
const ALLOWED_ROLES = ["admin", "mega_admin", "manager", "logistician", "driver"];
export const GroupDetailPage = () => {
// Route Params & Auth
const { groupId } = useParams();
const navigate = useNavigate();
const location = useLocation();
const { user } = useAuth();
const { user, isSessionLoading } = useAuth();
const userRole = user?.role;
// ALL hooks must be called before any early return (Rules of Hooks)
const {
allOrderGroups,
selectedOrderGroupId,
setSelectedOrderGroupId,
saveManualDeliveryChoice,
isSavingDeliveryChoice,
isSavingDriverAssignment,
isSavingStatusChange,
assignDriver,
changeDeliveryStatus,
isLoading,
} = useOrderGroups();
// Drivers
const [drivers, setDrivers] = React.useState([]);
React.useEffect(() => {
@ -45,9 +60,10 @@ export const GroupDetailPage = () => {
return () => { cancelled = true; };
}, []);
const order = allOrderGroups.find((g) => g.id === groupId) ||
// ALL hooks must be called before any early return (Rules of Hooks)
const order = isLoading ? null : (allOrderGroups.find((g) => g.id === groupId) ||
allOrderGroups.find((g) => g.id === selectedOrderGroupId) ||
null;
null);
// Preserve the tab the user came from when going back
const handleGoBack = React.useCallback(() => {
@ -58,6 +74,21 @@ export const GroupDetailPage = () => {
}
}, [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 (
<div className="mx-auto w-full max-w-3xl space-y-5">
<div className="flex items-center justify-between">
@ -66,20 +97,26 @@ export const GroupDetailPage = () => {
</Button>
</div>
{order ? (
<OrderDetailPanel
{isLoading ? (
<SkeletonPanel lines={6} />
) : order ? (
<ErrorBoundary compact>
<OrderDetailPanel
order={order}
canManageDelivery={["manager", "logistician", "admin", "mega_admin"].includes(userRole)}
onSaveManualDeliveryChoice={saveManualDeliveryChoice}
isSavingDeliveryChoice={isSavingDeliveryChoice}
isSavingDriverAssignment={isSavingDriverAssignment}
isSavingStatusChange={isSavingStatusChange}
drivers={drivers}
onAssignDriver={assignDriver}
onChangeDeliveryStatus={changeDeliveryStatus}
userRole={userRole}
/>
/>
</ErrorBoundary>
) : (
<Panel className="p-6 text-sm text-[var(--color-text-muted)]">
Группа доставки не найдена.
Группа не найдена.
</Panel>
)}
</div>

View File

@ -1,5 +1,5 @@
import React from "react";
import { Navigate } from "react-router-dom";
import { Navigate, useSearchParams } from "react-router-dom";
import { ROLE_LABELS } from "../constants/roles";
import { useAuth } from "../context/AuthContext";
import { demoUsers } from "../data/mockAppData";
@ -14,6 +14,9 @@ export const LoginPage = () => {
const [otp, setOtp] = React.useState("");
const [error, setError] = React.useState("");
const [searchParams] = useSearchParams();
const redirectUrl = searchParams.get("redirect") || "/dashboard";
const displayError = error || authError;
const handleRequestOtp = async () => {
@ -60,7 +63,7 @@ export const LoginPage = () => {
};
if (user) {
return <Navigate to="/dashboard" replace />;
return <Navigate to={redirectUrl} replace />;
}
return (

View File

@ -6,6 +6,7 @@ import { DashboardPage } from "./pages/DashboardPage";
import { GroupDetailPage } from "./pages/GroupDetailPage";
import { LoginPage } from "./pages/LoginPage";
import { NotFoundPage } from "./pages/NotFoundPage";
import { ForbiddenPage } from "./pages/ForbiddenPage";
export const router = createBrowserRouter([
{
@ -24,6 +25,10 @@ export const router = createBrowserRouter([
path: "delivery/:token",
element: <ClientDeliveryPage />,
},
{
path: "forbidden",
element: <ForbiddenPage />,
},
{
path: "dashboard",
element: <DashboardPage />,

View File

@ -223,11 +223,14 @@ export const fetchDeliveryInvitation = async (token) => {
}
};
export const confirmDeliveryChoice = async ({ token, deliveryDate, deliveryTime }) => {
export const confirmDeliveryChoice = async ({ token, deliveryDate, deliveryTime, deliveryType, pickupDate, pickupTimeSlot, deliveryAddress }) => {
if (isLocalClientInvitationToken(token)) {
const baseInvitation = getCachedInvitation(token) ?? buildFallbackInvitation(token);
const invitation = cacheInvitation({
...baseInvitation,
deliveryType: deliveryType || "delivery",
...(deliveryType === "pickup" ? { pickupDate, pickupTimeSlot } : {}),
...(deliveryType === "delivery" && deliveryAddress ? { deliveryAddress, customerAddress: deliveryAddress } : {}),
deliveryDate,
deliveryTime,
state: "confirmed",
@ -242,6 +245,10 @@ export const confirmDeliveryChoice = async ({ token, deliveryDate, deliveryTime
p_token: token,
p_delivery_date: deliveryDate,
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,6 +5,7 @@ const getDeliveryDate = (group) => normalizeDate(group.deliveryDate || group.cus
export const DELIVERY_GROUP_STATUS_LABELS = {
pending_confirmation: "Ожидает согласования",
manual_confirmation_required: "Взят в ручное управление",
requires_address: "Требуется адрес",
agreed: "Согласовано",
driver_assigned: "Назначен водитель",
loaded: "Загружено",
@ -12,6 +13,7 @@ export const DELIVERY_GROUP_STATUS_LABELS = {
delivered: "Доставлено",
problem: "Проблема",
paid_storage: "Платное хранение",
pickup: "Самовывоз",
cancelled: "Отменено",
};

View File

@ -8,6 +8,7 @@ import {
getOrderGroupDeliveryStatusLabel,
getOrderGroupStatusLabel,
} from "../orderGroupViews";
import { normalizeNom } from "../../utils/deliveryUtils";
const requireSupabase = () => {
if (!hasSupabaseConfig || !supabase) {
@ -61,6 +62,25 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
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 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 ordersCount = toNumber(row.orders_count ?? row.orders_total ?? row.legacy_orders_total, inferredOrderCount);
const readyCount = toNumber(
@ -90,6 +110,26 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
};
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 extractCity = (addr) => {
@ -131,7 +171,8 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
customerPhone,
customerPhoneNormalized: parsedKey.phone || normalizePhone(customerPhone),
customerDate,
deliveryAddress,
deliveryAddress: resolvedDeliveryAddress,
originalDeliveryAddress,
customerAddress,
city,
assignedDriverId: row.assigned_driver_id || null,
@ -140,6 +181,7 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
readyCount,
notReadyCount,
orderNumbers,
allBillNumbers,
status: row.status || "draft",
smsSentAt: row.sms_sent_at || null,
firstSmsSentAt: row.first_sms_sent_at || null,
@ -168,19 +210,23 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
deliveryDate,
deliveryTime,
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: rawDeliveryHalfDay,
deliveryTime: rawDeliveryTime,
deliveryWindow: row.delivery_window,
sourceOrders: row.source_orders,
}),
orderNumberSummary: orderNumbers.length ? orderNumbers.join(", ") : "Номера не указаны",
orderNumberSummary: allBillNumbers.length ? allBillNumbers.join(", ") : "Номера не указаны",
searchText: [
row.group_key,
customerName,
customerPhone,
customerDate,
deliveryAddress,
resolvedDeliveryAddress,
customerAddress,
city,
rawDeliveryHalfDay,
@ -189,6 +235,7 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
deliveryStatus,
getOrderGroupDeliveryStatusLabel(deliveryStatus),
orderNumbers.join(" "),
allBillNumbers.join(" "),
row.status,
getOrderGroupStatusLabel(row.status),
getOrderGroupDeliveryHalfDay({
@ -207,23 +254,42 @@ 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 ({
orderGroupId,
deliveryDate,
deliveryTime,
deliveryType,
deliveryAddress,
pickupDate,
pickupTimeSlot,
}) => {
return safeSupabaseCall(async () => {
const client = requireSupabase();
const effectiveDeliveryStatus = deliveryType === "pickup" ? "pickup" : "agreed";
const updatePayload = {
delivery_status: effectiveDeliveryStatus,
delivery_date: deliveryDate,
delivery_time: deliveryTime,
delivery_type: deliveryType || "delivery",
delivery_date_source: "manual",
notification_status: "confirmed",
updated_at: new Date().toISOString(),
};
if (deliveryType === "pickup") {
updatePayload.pickup_date = pickupDate || null;
updatePayload.pickup_time_slot = pickupTimeSlot || null;
} else {
updatePayload.pickup_date = null;
updatePayload.pickup_time_slot = null;
if (deliveryAddress !== undefined) {
updatePayload.delivery_address = deliveryAddress;
}
}
const updateResult = await client
.from("order_groups")
.update({
delivery_status: "agreed",
delivery_date: deliveryDate,
delivery_time: deliveryTime,
delivery_date_source: "manual",
notification_status: "confirmed",
updated_at: new Date().toISOString(),
})
.update(updatePayload)
.eq("id", orderGroupId);
if (updateResult.error) {
@ -232,7 +298,7 @@ export const updateOrderGroupDeliveryChoice = async ({
const { data, error } = await client
.from("order_groups")
.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)")
.select(ORDER_GROUP_SELECT_FIELDS)
.eq("id", orderGroupId)
.single();
@ -240,12 +306,13 @@ export const updateOrderGroupDeliveryChoice = async ({
throw error;
}
await logAction({ orderGroupId, action: "date_assigned", newValue: "manual: " + deliveryDate + " " + (deliveryTime || ""), details: { delivery_date_source: "manual" } }).catch(() => {});
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(() => {});
return mapOrderGroupRowToDeliveryGroup(data);
}, "Ошибка сохранения согласования доставки");
};
export const assignDriverToOrderGroup = async ({
orderGroupId,
driverId,
@ -386,7 +453,7 @@ export const fetchOrderGroups = async () => {
const client = requireSupabase();
const { data, error } = await client
.from("order_groups")
.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)")
.select(ORDER_GROUP_SELECT_FIELDS)
.order("updated_at", { ascending: false });
if (error) {

View File

@ -158,15 +158,16 @@ describe("updateOrderGroupDeliveryChoice", () => {
});
expect(fromMock).toHaveBeenCalledWith("order_groups");
expect(updateMock).toHaveBeenCalledWith({
expect(updateMock).toHaveBeenCalledWith(expect.objectContaining({
delivery_status: "agreed",
delivery_date: "2026-05-13",
delivery_time: "Первая половина дня",
delivery_type: "delivery",
notification_status: "confirmed",
updated_at: expect.any(String),
});
}));
expect(eqMock).toHaveBeenCalledWith("id", "group-id");
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(selectMock).toHaveBeenCalledWith(expect.stringContaining("delivery_type, pickup_date, pickup_time_slot"));
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);
/**
* Secure session storage for Supabase auth tokens.
* Secure 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:
* - Uses sessionStorage (dies on tab close, not shared across tabs)
* - Tokens are obfuscated with a per-session random key before storage
* - No plaintext tokens in sessionStorage reduces impact of XSS
* - Tokens are obfuscated with a per-browser random key stored in localStorage
* - No plaintext tokens in localStorage reduces impact of XSS
* - 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),
* 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
* but is the standard approach for SPA auth with Supabase.
*/
const STORAGE_KEY = "supersam-auth";
const KEY_KEY = "supersam-ak";
function _getKey() {
let key = sessionStorage.getItem(KEY_KEY);
let key = localStorage.getItem(KEY_KEY);
if (!key) {
key = crypto.getRandomValues(new Uint8Array(32)).reduce(
(s, b) => s + b.toString(16).padStart(2, "0"),
""
);
sessionStorage.setItem(KEY_KEY, key);
localStorage.setItem(KEY_KEY, key);
}
return key;
}
@ -60,15 +60,15 @@ async function _deobfuscate(obfuscated) {
return new TextDecoder().decode(result);
} catch {
// Tampered data clear everything
sessionStorage.removeItem(STORAGE_KEY);
sessionStorage.removeItem(KEY_KEY);
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(KEY_KEY);
return "";
}
}
const secureStorage = {
getItem: async (key) => {
const raw = sessionStorage.getItem(STORAGE_KEY);
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
try {
const data = JSON.parse(raw);
@ -76,34 +76,34 @@ const secureStorage = {
if (typeof value !== "string") return null;
return await _deobfuscate(value);
} catch {
sessionStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(STORAGE_KEY);
return null;
}
},
setItem: async (key, value) => {
let data;
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
const raw = localStorage.getItem(STORAGE_KEY);
data = raw ? JSON.parse(raw) : {};
} catch {
data = {};
}
data[key] = await _obfuscate(value);
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(data));
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
},
removeItem: async (key) => {
const raw = sessionStorage.getItem(STORAGE_KEY);
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
try {
const data = JSON.parse(raw);
delete data[key];
if (Object.keys(data).length === 0) {
sessionStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(STORAGE_KEY);
} else {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(data));
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
}
} catch {
sessionStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(STORAGE_KEY);
}
},
};

View File

@ -0,0 +1,58 @@
/**
* 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 "@supabase/supabase-js";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.8";
import { getOrderUpdateForInboundAction } from "./workflow.ts";
export type ProviderName = "telegram" | "vk" | "messenger_max";

View File

@ -120,15 +120,25 @@ export const normalizeAvailableSlots = (availableSlots?: string[] | null) => {
};
export const buildDefaultDatedAvailableSlots = (now = new Date()) => {
const formatIsoDate = (date: Date) => date.toISOString().slice(0, 10);
const CRIMEA_TZ = "Europe/Simferopol";
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 next = new Date(date);
next.setUTCDate(next.getUTCDate() + days);
return next;
};
const firstDay = formatIsoDate(addDays(now, 1));
const secondDay = formatIsoDate(addDays(now, 2));
const firstDay = formatCrimeaDate(addDays(now, 1));
const secondDay = formatCrimeaDate(addDays(now, 2));
return [
`${firstDay}, Первая половина дня`,

View File

@ -1,399 +1,172 @@
type CorsMode = "public" | "integration" | "webhook";
import { createClient } from 'npm:@supabase/supabase-js@2';
type JsonBodyOptions = {
maxBytes: number;
errorMessage?: string;
};
const ALLOWED_ORIGINS = [
'https://supa.supersamsev.ru',
'https://dost.supersamsev.ru',
'http://localhost:5173',
'http://localhost:5174',
'http://localhost:3000',
'https://supasevdev.mkn8n.ru',
];
type RateLimitOptions = {
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;
};
blockSeconds: number;
}
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 {
class RateLimitError extends Error {
status: number;
constructor(status: number, message: string) {
constructor(message: string, status: number) {
super(message);
this.status = status;
this.name = "HttpError";
}
}
export const jsonResponse = (
body: unknown,
status = 200,
headers: HeadersInit = {},
) =>
new Response(JSON.stringify(body), {
status,
headers: {
"Content-Type": "application/json",
...headers,
},
});
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();
export const getCorsHeaders = (request: Request, mode: CorsMode) => {
const origin = getRequestOrigin(request);
const allowedOrigins = resolveAllowedOrigins(mode);
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 (!origin) {
if (allowedOrigins.length === 0) {
return null;
}
return {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-request-id, x-signature, x-timestamp, x-webhook-secret",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Max-Age": "86400",
Vary: "Origin",
} satisfies Record<string, string>;
if (blocked && blocked.length > 0) {
throw new RateLimitError('Too many requests. Please try again later.', 429);
}
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,
});
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) {
throw error;
console.error('Rate limit check error:', error);
return;
}
if (!data?.allowed) {
throw new HttpError(429, "Too many requests");
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);
}
return data;
};
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

@ -24,6 +24,9 @@ type ConfirmBody = {
token?: string;
deliveryDate?: string;
deliveryTime?: string;
deliveryType?: string;
pickupDate?: string;
pickupTimeSlot?: string;
};
const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
@ -36,6 +39,7 @@ const resolveRequestedSlot = (
},
body: ConfirmBody,
) => {
const deliveryType = body.deliveryType || "delivery";
const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim();
const deliveryTime = String(body.deliveryTime || invitation.delivery_time || "").trim();
@ -43,6 +47,11 @@ const resolveRequestedSlot = (
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 availableSlots = invitation.available_slots || [];
@ -50,7 +59,7 @@ const resolveRequestedSlot = (
return null;
}
return { deliveryDate, deliveryTime };
return { deliveryDate, deliveryTime, deliveryType };
};
Deno.serve(async (request) => {
@ -127,6 +136,16 @@ Deno.serve(async (request) => {
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) {
const { data: currentGroup, error: groupError } = await supabase
.from("order_groups")
@ -177,15 +196,23 @@ Deno.serve(async (request) => {
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
.from("order_groups")
.update({
delivery_status: "agreed",
delivery_date: requestedSlot.deliveryDate,
delivery_time: requestedSlot.deliveryTime,
notification_status: "confirmed",
updated_at: new Date().toISOString(),
})
.update(groupUpdateData)
.eq("id", invitation.order_group_id);
if (groupUpdateError) {
@ -197,10 +224,13 @@ Deno.serve(async (request) => {
order_group_id: invitation.order_group_id,
action: "client_confirmed",
old_value: currentGroup.delivery_status,
new_value: "agreed",
new_value: effectiveDeliveryStatus,
details: {
delivery_date: requestedSlot.deliveryDate,
delivery_time: requestedSlot.deliveryTime,
delivery_type: deliveryType,
pickup_date: body.pickupDate || null,
pickup_time_slot: body.pickupTimeSlot || null,
source: "auto",
},
});
@ -215,6 +245,9 @@ Deno.serve(async (request) => {
delivery_invitation_id: invitation.id,
delivery_date: requestedSlot.deliveryDate,
delivery_time: requestedSlot.deliveryTime,
delivery_type: deliveryType,
pickup_date: body.pickupDate || null,
pickup_time_slot: body.pickupTimeSlot || null,
},
});
@ -222,7 +255,7 @@ Deno.serve(async (request) => {
{
ok: true,
orderGroupId: invitation.order_group_id,
deliveryStatus: "agreed",
deliveryStatus: effectiveDeliveryStatus,
},
200,
corsHeaders,
@ -314,6 +347,9 @@ Deno.serve(async (request) => {
new_delivery_agreement_status: orderUpdate?.deliveryAgreementStatus,
delivery_date: requestedSlot.deliveryDate,
delivery_time: requestedSlot.deliveryTime,
delivery_type: deliveryType,
pickup_date: body.pickupDate || null,
pickup_time_slot: body.pickupTimeSlot || null,
},
});
@ -329,6 +365,9 @@ Deno.serve(async (request) => {
payload: {
delivery_date: requestedSlot.deliveryDate,
delivery_time: requestedSlot.deliveryTime,
delivery_type: deliveryType,
pickup_date: body.pickupDate || null,
pickup_time_slot: body.pickupTimeSlot || null,
},
});

View File

@ -0,0 +1,168 @@
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 { createAnonClient } from "../_shared/chatbot.ts";
import { createServiceClient } from "../_shared/security.ts";
import {
getClientIp,
getCorsHeaders,
@ -14,6 +14,17 @@ 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");
@ -38,7 +49,7 @@ Deno.serve(async (request) => {
return jsonResponse({ ok: false, error: "Valid email is required" }, 400, corsHeaders);
}
const supabase = createAnonClient();
const supabase = createServiceClient();
const emailHash = await hashText(email);
const ipHash = await hashText(getClientIp(request));
@ -50,15 +61,50 @@ Deno.serve(async (request) => {
blockSeconds: 1800,
});
const { error } = await supabase.auth.signInWithOtp({
// 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,
options: {
shouldCreateUser: false,
},
name: userName,
role: userRole,
otp_code: otp,
otp_code_hash: otpCodeHash,
ip_address: clientIp,
user_agent: userAgent,
verified: false,
});
if (error) {
return jsonResponse({ ok: false, error: error.message }, 400, corsHeaders);
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);

View File

@ -1,4 +1,4 @@
import { createAnonClient } from "../_shared/chatbot.ts";
import { createServiceClient } from "../_shared/security.ts";
import {
getClientIp,
getCorsHeaders,
@ -7,10 +7,10 @@ import {
preflightResponse,
readJsonBody,
requireRateLimit,
requireSameOrigin,
} 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());
@ -29,19 +29,6 @@ Deno.serve(async (request) => {
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<{ email?: string; otp?: string }>(request, {
maxBytes: MAX_BODY_BYTES,
@ -57,7 +44,7 @@ Deno.serve(async (request) => {
return jsonResponse({ ok: false, error: "Valid OTP is required" }, 400, corsHeaders);
}
const supabase = createAnonClient();
const supabase = createServiceClient();
const emailHash = await hashText(email);
const ipHash = await hashText(getClientIp(request));
@ -69,21 +56,118 @@ Deno.serve(async (request) => {
blockSeconds: 1800,
});
const { data, error } = await supabase.auth.verifyOtp({
// 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,
token: otp,
type: "email",
});
if (error) {
return jsonResponse({ ok: false, error: error.message }, 400, corsHeaders);
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: data.session || null,
user: data.session?.user || null,
session: session || null,
user: user || null,
},
200,
corsHeaders,

49
volumes/api/kong-entrypoint.sh Executable file
View File

@ -0,0 +1,49 @@
#!/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

411
volumes/api/kong.yml Normal file
View File

@ -0,0 +1,411 @@
_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

@ -0,0 +1,83 @@
# 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

@ -0,0 +1,72 @@
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

@ -0,0 +1,84 @@
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

@ -0,0 +1,313 @@
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

@ -0,0 +1,30 @@
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

@ -0,0 +1,172 @@
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

@ -0,0 +1,35 @@
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

@ -0,0 +1,44 @@
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

@ -0,0 +1,141 @@
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

@ -0,0 +1,360 @@
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

@ -0,0 +1,409 @@
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

@ -0,0 +1,191 @@
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

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

View File

@ -0,0 +1,168 @@
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

@ -0,0 +1,158 @@
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

@ -0,0 +1,126 @@
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

@ -0,0 +1,152 @@
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

@ -0,0 +1,156 @@
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

@ -0,0 +1,230 @@
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

@ -0,0 +1,190 @@
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,
);
}
});

76
webhook-listener.py Executable file
View File

@ -0,0 +1,76 @@
#!/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()