Harden auth and delivery endpoints
This commit is contained in:
parent
5dcfa80940
commit
e29a51e7ea
|
|
@ -1,5 +1,11 @@
|
|||
VITE_SUPABASE_URL=https://your-project.supabase.co
|
||||
VITE_SUPABASE_ANON_KEY=your-anon-key
|
||||
APP_ALLOWED_ORIGINS=http://localhost:5173
|
||||
INTEGRATION_ALLOWED_ORIGINS=http://localhost:5173
|
||||
WEBHOOK_ALLOWED_ORIGINS=http://localhost:5173
|
||||
INTEGRATION_API_KEY=replace-me
|
||||
INTEGRATION_WEBHOOK_SECRET=replace-me
|
||||
CHATBOT_WEBHOOK_SECRET=replace-me
|
||||
|
||||
# Self-hosted Supabase auth
|
||||
SUPABASE_PUBLIC_URL=https://supa.example.com
|
||||
|
|
|
|||
|
|
@ -0,0 +1,403 @@
|
|||
# План устранения уязвимостей и защиты персональных данных
|
||||
|
||||
Дата аудита: 2026-04-29
|
||||
|
||||
## 1. Контекст и поверхность атаки
|
||||
|
||||
Приложение работает как веб-кабинет доставки с персональными данными клиентов: ФИО, телефон, адрес, состав заказа, история доставки, выбранные слоты, сообщения и статусы. Основные поверхности атаки:
|
||||
|
||||
- `/login` — вход сотрудников по email OTP.
|
||||
- `/dashboard` — авторизованные кабинеты сотрудников.
|
||||
- `/delivery/:token` — публичная клиентская ссылка без логина.
|
||||
- Supabase Edge Functions — публичные и интеграционные endpoints.
|
||||
- Supabase Postgres — таблицы `orders`, `users`, `delivery_invitations`, `chat_messages`, `order_history`, `delivery_slots`, `integration_events`.
|
||||
- PWA/localStorage — локальное хранение состояния и клиентских приглашений.
|
||||
|
||||
## 2. Что уже сделано хорошо
|
||||
|
||||
- Включены RLS-политики на основных таблицах: `roles`, `users`, `orders`, `order_history`, `delivery_slots`, `chat_messages`, `delivery_invitations`, `integration_events`.
|
||||
- Для `orders` доступ ограничен ролью и назначением: менеджер, логист, водитель, администратор.
|
||||
- Invitation token в БД хранится как `token_hash`, а не как открытый token.
|
||||
- Клиентская ссылка работает без авторизованного пользователя, но доступ идёт по token.
|
||||
- Email OTP настроен с `shouldCreateUser: false`, то есть саморегистрация через фронтенд не должна создавать новых пользователей.
|
||||
|
||||
## 3. Критические риски
|
||||
|
||||
### P0. Нет rate limits на OTP, публичные ссылки и Edge Functions
|
||||
|
||||
Найдено:
|
||||
|
||||
- `src/context/AuthContext.jsx:117` вызывает `supabase.auth.signInWithOtp(...)` напрямую из браузера.
|
||||
- `supabase/functions/get-delivery-invitation/index.ts:57` и `supabase/functions/confirm-delivery-choice/index.ts:14` принимают публичные запросы без лимитов.
|
||||
- `supabase/functions/create-delivery-invitation/index.ts:17`, `transfer-to-logistics`, `report-delivery-result`, `chatbot-webhook` также не имеют throttle/rate limit.
|
||||
|
||||
Риск:
|
||||
|
||||
- Заспамить OTP-письма на один email или набор email.
|
||||
- Брутфорсить invitation token.
|
||||
- Массово дергать клиентские ссылки и собирать признаки существующих заказов.
|
||||
- Перегрузить Edge Functions и базу.
|
||||
- Создать много `delivery_slots`, `order_history`, `integration_events` через повторные запросы.
|
||||
|
||||
План устранения:
|
||||
|
||||
1. Добавить таблицу `public.rate_limits`:
|
||||
|
||||
```sql
|
||||
create table public.rate_limits (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
scope text not null,
|
||||
key text not null,
|
||||
window_start timestamptz not null,
|
||||
count integer not null default 1,
|
||||
blocked_until timestamptz,
|
||||
created_at timestamptz not null default timezone('utc', now()),
|
||||
updated_at timestamptz not null default timezone('utc', now()),
|
||||
unique (scope, key, window_start)
|
||||
);
|
||||
```
|
||||
|
||||
2. Добавить SQL/RPC `public.check_rate_limit(scope, key, max_count, window_seconds, block_seconds)` с атомарным `insert ... on conflict ... update`.
|
||||
3. Для Edge Functions сделать shared helper `supabase/functions/_shared/rate-limit.ts`.
|
||||
4. Лимиты по умолчанию:
|
||||
|
||||
| Endpoint | Ключ | Лимит |
|
||||
|---|---|---|
|
||||
| OTP request | `ip + email_hash` | 3 запроса / 10 минут, блок 30 минут |
|
||||
| OTP verify | `ip + email_hash` | 5 попыток / 10 минут, блок 30 минут |
|
||||
| get invitation | `ip + token_hash_prefix` | 30 запросов / 10 минут |
|
||||
| confirm delivery | `ip + token_hash` | 5 POST / 10 минут, блок 1 час |
|
||||
| create invitation | `integration_key + order_id` | 10 запросов / 10 минут |
|
||||
| chatbot webhook | `provider + external_message_id/ip` | 60 запросов / минуту |
|
||||
| report delivery result | `integration_key + order_id` | 10 запросов / 10 минут |
|
||||
|
||||
5. Для `/login` лучше не вызывать Supabase OTP напрямую из браузера, а добавить Edge Function `request-otp`, которая валидирует email, применяет rate limit и только потом инициирует OTP.
|
||||
6. Добавить тесты на rate-limit helper: allowed, exhausted, blocked, reset after window.
|
||||
|
||||
### P0. Service role используется в публичных functions без отдельной авторизации
|
||||
|
||||
Найдено:
|
||||
|
||||
- `supabase/functions/_shared/chatbot.ts:18` создаёт Supabase client через `SUPABASE_SERVICE_ROLE_KEY`.
|
||||
- `create-delivery-invitation`, `confirm-delivery-choice`, `transfer-to-logistics`, `report-delivery-result`, `chatbot-webhook` используют service client и обходят RLS.
|
||||
- `create-delivery-invitation` принимает `orderId` из тела запроса и обновляет заказ через service role.
|
||||
- `report-delivery-result` и `transfer-to-logistics` также меняют заказ по `orderId` без проверки пользователя/подписи.
|
||||
|
||||
Риск:
|
||||
|
||||
- Если endpoint доступен извне, злоумышленник может менять статусы заказов, создавать приглашения, переводить в платное хранение или доставлено, если узнает/подберёт `orderId`.
|
||||
- RLS не помогает, потому что service role её обходит.
|
||||
|
||||
План устранения:
|
||||
|
||||
1. Разделить Edge Functions на публичные и интеграционные:
|
||||
- публичные: `get-delivery-invitation`, `confirm-delivery-choice`;
|
||||
- внутренние/integration-only: `create-delivery-invitation`, `transfer-to-logistics`, `report-delivery-result`, `chatbot-webhook`, `send-chatbot-message`.
|
||||
2. Для internal functions требовать `Authorization: Bearer <INTEGRATION_API_KEY>` или HMAC-подпись:
|
||||
- `X-Signature: hex(hmac_sha256(raw_body, secret))`
|
||||
- `X-Timestamp`
|
||||
- `X-Request-Id`
|
||||
3. Отклонять запросы старше 5 минут.
|
||||
4. Хранить обработанные `X-Request-Id` в `integration_events` или отдельной таблице `idempotency_keys`.
|
||||
5. Добавить `verifyIntegrationRequest(request, rawBody)` в `_shared/auth.ts`.
|
||||
6. Для functions, вызываемых из авторизованного UI, проверять JWT пользователя через anon client или `supabase.auth.getUser(jwt)`, а service role использовать только после проверки роли.
|
||||
|
||||
### P0. CORS открыт на все origin
|
||||
|
||||
Найдено:
|
||||
|
||||
- `Access-Control-Allow-Origin: "*"` в `get-delivery-invitation`, `confirm-delivery-choice`, `create-delivery-invitation`, `transfer-to-logistics`, `report-delivery-result`, `chatbot-webhook`.
|
||||
|
||||
Риск:
|
||||
|
||||
- Любой сайт может дергать публичные endpoints из браузера.
|
||||
- Для публичной клиентской ссылки это допустимо только частично, но для integration-only endpoints опасно.
|
||||
|
||||
План устранения:
|
||||
|
||||
1. Добавить shared helper `cors.ts`.
|
||||
2. Настроить allowlist через env:
|
||||
- `PUBLIC_APP_URL`
|
||||
- `APP_ALLOWED_ORIGINS`
|
||||
- `INTEGRATION_ALLOWED_ORIGINS`
|
||||
3. Для public invitation endpoints разрешить только домены приложения.
|
||||
4. Для integration-only endpoints либо не отвечать CORS вообще, либо разрешить только внутренний origin/n8n.
|
||||
5. На неизвестный origin возвращать `403`.
|
||||
|
||||
### P1. Token публичной ссылки не имеет срока жизни и политики ротации
|
||||
|
||||
Найдено:
|
||||
|
||||
- `delivery_invitations` не содержит `expires_at`.
|
||||
- `get-delivery-invitation` возвращает данные, если token найден и состояние вычислено по заказу.
|
||||
- `confirm-delivery-choice` проверяет active state, но не проверяет срок жизни token.
|
||||
|
||||
Риск:
|
||||
|
||||
- Старая ссылка остаётся рабочей слишком долго.
|
||||
- Утечка ссылки даёт доступ к данным заказа и действию подтверждения.
|
||||
|
||||
План устранения:
|
||||
|
||||
1. Добавить поля:
|
||||
|
||||
```sql
|
||||
alter table public.delivery_invitations
|
||||
add column if not exists expires_at timestamptz,
|
||||
add column if not exists revoked_at timestamptz,
|
||||
add column if not exists last_accessed_at timestamptz,
|
||||
add column if not exists access_count integer not null default 0;
|
||||
```
|
||||
|
||||
2. При создании invitation ставить `expires_at = now() + interval '7 days'` или другой срок по ТЗ.
|
||||
3. В `get-delivery-invitation` и `confirm-delivery-choice` возвращать `410 Gone`, если `expires_at < now()` или `revoked_at is not null`.
|
||||
4. После успешного `confirm-delivery-choice` запрещать повторное подтверждение, кроме идемпотентного возврата текущего выбора.
|
||||
5. Добавить ротацию token при повторной отправке ссылки.
|
||||
|
||||
### P1. Публичная ссылка раскрывает лишние персональные данные
|
||||
|
||||
Найдено:
|
||||
|
||||
- `get-delivery-invitation` возвращает `customerName`, `customerPhone`, `orderNumber`, `orderItems`, `orderStatus`, `deliveryAgreementStatus`.
|
||||
|
||||
Риск:
|
||||
|
||||
- При утечке token третье лицо видит телефон, имя, состав заказа и статус.
|
||||
- Эти данные могут быть использованы для социальной инженерии.
|
||||
|
||||
План устранения:
|
||||
|
||||
1. Минимизировать ответ публичной страницы:
|
||||
- показывать имя частично: `Мария В.`;
|
||||
- телефон маскировать: `+7 *** ***-12-31`;
|
||||
- номер заказа показывать частично или только последние 4 символа;
|
||||
- состав заказа показывать укрупнённо, если это не нужно клиенту для выбора слота.
|
||||
2. Не возвращать `orderStatus` и `deliveryAgreementStatus` в сыром виде, отдавать только public state: `awaiting_choice`, `agreed`, `delivered`, `expired`.
|
||||
3. В `delivery_invitations` хранить public-safe snapshot, чтобы не тянуть весь `orders.customer`.
|
||||
4. Добавить тест, что public API не отдаёт полный телефон и внутренний статус.
|
||||
|
||||
### P1. Webhook не проверяет подпись провайдера и не ограничивает provider
|
||||
|
||||
Найдено:
|
||||
|
||||
- `chatbot-webhook` берёт `provider` из query string.
|
||||
- Тело события нормализуется без проверки подписи.
|
||||
- `external_message_id` есть, но повторная вставка при конфликте может давать ошибку и 500, а не идемпотентный `ok`.
|
||||
|
||||
Риск:
|
||||
|
||||
- Любой может отправить fake webhook и изменить заказ.
|
||||
- Возможен replay одного webhook.
|
||||
- Возможна подмена provider.
|
||||
|
||||
План устранения:
|
||||
|
||||
1. Для каждого provider добавить проверку подписи:
|
||||
- Telegram: secret token/header или webhook secret path.
|
||||
- VK/Max: HMAC/secret по контракту провайдера.
|
||||
2. Убрать произвольный `provider` из query или валидировать строго по allowlist.
|
||||
3. Для `external_message_id` реализовать `upsert`/идемпотентный возврат `ok: true` при повторе.
|
||||
4. В payload логировать только безопасные поля, без полного сырого тела, если оно содержит ПДн.
|
||||
|
||||
### P1. Нет ограничений на размер JSON и длину полей
|
||||
|
||||
Найдено:
|
||||
|
||||
- Edge Functions делают `await request.json()` без проверки `Content-Length`.
|
||||
- `chatbot-webhook` записывает `text` и `payload` как пришли.
|
||||
- `confirm-delivery-choice` принимает `deliveryDate` и `deliveryTime` без строгой проверки формата и allowed values.
|
||||
|
||||
Риск:
|
||||
|
||||
- DoS через большой body.
|
||||
- Загрязнение истории/логов огромным payload.
|
||||
- Некорректные даты и значения слотов.
|
||||
|
||||
План устранения:
|
||||
|
||||
1. Добавить helper `readJsonBody(request, { maxBytes })`.
|
||||
2. Лимиты:
|
||||
- public endpoints: 8 KB;
|
||||
- webhook: 64 KB;
|
||||
- internal dispatch: 16 KB.
|
||||
3. Валидировать:
|
||||
- UUID для `orderId`;
|
||||
- `deliveryDate` как `YYYY-MM-DD`;
|
||||
- `deliveryTime` только из `available_slots`;
|
||||
- `note`, `reason`, `text` по максимальной длине.
|
||||
4. Возвращать `413 Payload Too Large` и `422 Unprocessable Entity`.
|
||||
|
||||
### P1. Политики RLS слишком широкие для некоторых ролей
|
||||
|
||||
Найдено:
|
||||
|
||||
- `users self or admin` фактически разрешает всем авторизованным пользователям читать всех пользователей.
|
||||
- `orders update by workflow role` позволяет `production_lead` обновлять все заказы.
|
||||
- `orders insert managers admin` всё ещё разрешает менеджеру создавать заказы, хотя по текущему ТЗ заказы приходят из 1С.
|
||||
|
||||
Риск:
|
||||
|
||||
- Лишний доступ к email/пользователям.
|
||||
- Шире нужного доступ к заказам и ПДн.
|
||||
- Внутренний пользователь может создать заказ вручную.
|
||||
|
||||
План устранения:
|
||||
|
||||
1. Разделить policy users:
|
||||
- self может читать только себя;
|
||||
- admin читает всех;
|
||||
- при необходимости логист/менеджер получают только `id, name, role`, без email/last_login.
|
||||
2. Запретить insert заказов из UI для manager, если 1С является единственным источником.
|
||||
3. Ограничить `production_lead` только source/production полями или убрать роль из текущего демонстрационного контура.
|
||||
4. Добавить database policy tests через Supabase local или SQL assertions.
|
||||
|
||||
### P2. Локальный fallback содержит демонстрационные персональные данные
|
||||
|
||||
Найдено:
|
||||
|
||||
- `src/services/deliveryInvitationApi.js` содержит `showcase`, `client-flow-*`, имя, телефон и состав заказа.
|
||||
- `AuthContext` в fallback принимает OTP `000000`.
|
||||
|
||||
Риск:
|
||||
|
||||
- При production build без env приложение может работать в локальном/demo режиме.
|
||||
- Демонстрационные данные могут быть восприняты как реальные.
|
||||
|
||||
План устранения:
|
||||
|
||||
1. Добавить build guard: production build падает без `VITE_SUPABASE_URL` и `VITE_SUPABASE_ANON_KEY`.
|
||||
2. Обернуть fallback только в `import.meta.env.DEV`.
|
||||
3. В production не поддерживать `showcase` и `client-flow-*`.
|
||||
4. Добавить тест, что в production mode fallback недоступен.
|
||||
|
||||
### P2. Ошибки возвращают внутренние сообщения наружу
|
||||
|
||||
Найдено:
|
||||
|
||||
- Edge Functions возвращают `error instanceof Error ? error.message : "Unexpected error"` клиенту.
|
||||
|
||||
Риск:
|
||||
|
||||
- Утечка деталей Supabase, структуры таблиц, provider errors.
|
||||
|
||||
План устранения:
|
||||
|
||||
1. Ввести `publicError(status, code, message)` и `logInternalError(error, context)`.
|
||||
2. Клиенту отдавать стабильные коды: `not_found`, `expired`, `rate_limited`, `invalid_payload`, `temporary_error`.
|
||||
3. Внутреннюю ошибку писать в `error_logs`/observability без ПДн.
|
||||
|
||||
## 4. План работ по этапам
|
||||
|
||||
### Этап 1. Срочная защита от спама и неавторизованных изменений
|
||||
|
||||
Срок: 1-2 дня.
|
||||
|
||||
- Добавить `rate_limits` и shared rate-limit helper.
|
||||
- Закрыть `create-delivery-invitation`, `transfer-to-logistics`, `report-delivery-result`, `send-chatbot-message`, `chatbot-webhook` через integration secret/HMAC.
|
||||
- Ограничить CORS для Edge Functions.
|
||||
- Добавить проверку размера JSON body.
|
||||
- Добавить smoke-тесты на `429`, `401`, `403`.
|
||||
|
||||
Критерий готовности:
|
||||
|
||||
- Нельзя массово дергать OTP/public endpoints без `429`.
|
||||
- Нельзя изменить заказ через integration endpoint без валидного секрета.
|
||||
- Unknown origin не получает CORS-доступ.
|
||||
|
||||
### Этап 2. Защита публичной клиентской ссылки
|
||||
|
||||
Срок: 1-2 дня.
|
||||
|
||||
- Добавить `expires_at`, `revoked_at`, `access_count`, `last_accessed_at`.
|
||||
- Проверять срок действия token.
|
||||
- Маскировать phone/name/orderNumber.
|
||||
- Убрать внутренние `orderStatus` и `deliveryAgreementStatus` из public response.
|
||||
- Проверять `deliveryDate/deliveryTime` против доступных слотов.
|
||||
- Сделать подтверждение выбора идемпотентным.
|
||||
|
||||
Критерий готовности:
|
||||
|
||||
- Просроченная ссылка возвращает `410`.
|
||||
- Public API не отдаёт полный телефон и внутренние статусы.
|
||||
- Повторное подтверждение не создаёт дубли `delivery_slots`.
|
||||
|
||||
### Этап 3. Ужесточение RLS и ролей
|
||||
|
||||
Срок: 1 день.
|
||||
|
||||
- Сузить `users` policy.
|
||||
- Запретить создание заказов менеджером, если источник только 1С.
|
||||
- Проверить права `production_lead` и убрать из текущего UI/политик, если роль не используется.
|
||||
- Добавить SQL tests на доступ manager/logistician/driver/admin.
|
||||
|
||||
Критерий готовности:
|
||||
|
||||
- Сотрудник не может читать список email всех пользователей без нужной роли.
|
||||
- Менеджер не создаёт заказ напрямую через Supabase client.
|
||||
- Водитель видит только свои доставки.
|
||||
|
||||
### Этап 4. Production hardening
|
||||
|
||||
Срок: 1 день.
|
||||
|
||||
- Отключить demo/local fallback в production.
|
||||
- Добавить security headers:
|
||||
- `Content-Security-Policy`;
|
||||
- `X-Content-Type-Options: nosniff`;
|
||||
- `Referrer-Policy: no-referrer`;
|
||||
- `Permissions-Policy`;
|
||||
- `Strict-Transport-Security` на уровне хостинга.
|
||||
- Убедиться, что service worker не кеширует ответы с ПДн.
|
||||
- Добавить redaction для логов: телефоны, email, адреса, token.
|
||||
|
||||
Критерий готовности:
|
||||
|
||||
- Production build невозможен без Supabase env.
|
||||
- В кэше нет API-ответов с ПДн.
|
||||
- Логи не содержат полные телефоны/email/token.
|
||||
|
||||
## 5. Рекомендуемые значения лимитов
|
||||
|
||||
| Сценарий | Лимит | Блокировка |
|
||||
|---|---:|---:|
|
||||
| Запрос OTP на email | 3 / 10 минут | 30 минут |
|
||||
| Проверка OTP | 5 / 10 минут | 30 минут |
|
||||
| Открытие public invitation | 30 / 10 минут | 10 минут |
|
||||
| Подтверждение выбора доставки | 5 / 10 минут | 1 час |
|
||||
| Создание invitation | 10 / 10 минут на orderId | 30 минут |
|
||||
| Webhook сообщения | 60 / минуту на provider | 5 минут |
|
||||
| Ошибки 4xx/5xx с одного IP | 100 / 10 минут | 30 минут |
|
||||
|
||||
## 6. Проверки после внедрения
|
||||
|
||||
- Unit tests для rate-limit helper.
|
||||
- Edge Function tests:
|
||||
- no secret -> `401`;
|
||||
- wrong origin -> `403`;
|
||||
- too many requests -> `429`;
|
||||
- too large body -> `413`;
|
||||
- invalid payload -> `422`;
|
||||
- expired invitation -> `410`.
|
||||
- RLS tests:
|
||||
- manager видит только свои заказы;
|
||||
- logistician видит только назначенные наборы;
|
||||
- driver видит только назначенные доставки;
|
||||
- обычный пользователь не читает всех `users`.
|
||||
- Manual security checklist:
|
||||
- OTP нельзя заспамить;
|
||||
- публичная ссылка не раскрывает полный телефон;
|
||||
- повторный webhook не дублирует событие;
|
||||
- интеграционный endpoint без подписи не меняет заказ.
|
||||
|
||||
## 7. Приоритетный backlog
|
||||
|
||||
1. `P0` Rate limits для OTP и public/integration Edge Functions.
|
||||
2. `P0` Integration auth/HMAC для service-role endpoints.
|
||||
3. `P0` CORS allowlist вместо `*`.
|
||||
4. `P1` Expiration/revocation invitation token.
|
||||
5. `P1` Минимизация ПДн в public invitation response.
|
||||
6. `P1` Валидация payload и body size limits.
|
||||
7. `P1` Ужесточение RLS для `users` и `orders insert`.
|
||||
8. `P2` Production build guard против demo fallback.
|
||||
9. `P2` Security headers и service-worker cache policy.
|
||||
10. `P2` Redaction логов и мониторинг подозрительной активности.
|
||||
|
|
@ -120,10 +120,12 @@ export const AuthProvider = ({ children }) => {
|
|||
setAuthError("");
|
||||
try {
|
||||
if (hasSupabaseConfig && supabase) {
|
||||
const { error } = await supabase.auth.signInWithOtp(buildOtpRequestPayload(email));
|
||||
const { data, error } = await supabase.functions.invoke("request-otp", {
|
||||
body: buildOtpRequestPayload(email),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw normalizeOtpError(error);
|
||||
if (error || data?.ok === false) {
|
||||
throw normalizeOtpError(error || new Error(data?.error || PROFILE_LOAD_ERROR));
|
||||
}
|
||||
} else {
|
||||
localStorage.setItem("construction-auth-role-hint", roleHint || "manager");
|
||||
|
|
@ -144,19 +146,30 @@ export const AuthProvider = ({ children }) => {
|
|||
setIsLoading(true);
|
||||
try {
|
||||
if (hasSupabaseConfig && supabase) {
|
||||
const { data, error } = await supabase.auth.verifyOtp({
|
||||
email,
|
||||
token: otp,
|
||||
type: "email",
|
||||
const { data, error } = await supabase.functions.invoke("verify-otp", {
|
||||
body: { email, otp },
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw normalizeOtpError(error);
|
||||
if (error || data?.ok === false) {
|
||||
throw normalizeOtpError(error || new Error(data?.error || PROFILE_LOAD_ERROR));
|
||||
}
|
||||
|
||||
setUser(mapSessionUserToAuthUser(data.session?.user));
|
||||
if (data?.session?.access_token && data?.session?.refresh_token) {
|
||||
const { data: sessionData, error: sessionError } = await supabase.auth.setSession({
|
||||
access_token: data.session.access_token,
|
||||
refresh_token: data.session.refresh_token,
|
||||
});
|
||||
|
||||
if (sessionError) {
|
||||
throw normalizeOtpError(sessionError);
|
||||
}
|
||||
|
||||
setUser(mapSessionUserToAuthUser(sessionData.session?.user || data.session.user));
|
||||
} else {
|
||||
setUser(mapSessionUserToAuthUser(data?.user || null));
|
||||
}
|
||||
setAuthError("");
|
||||
return { success: Boolean(data.session) };
|
||||
return { success: Boolean(data?.session || data?.user) };
|
||||
}
|
||||
|
||||
if (otp !== "000000") {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@
|
|||
Принимает webhook от `telegram`, `vk`, `messenger_max`, нормализует сообщение, пишет его в
|
||||
`chat_messages` и при необходимости обновляет статус заказа и `order_history`.
|
||||
|
||||
Требует подпись `X-Signature` или `Authorization: Bearer <INTEGRATION_API_KEY>`, а также
|
||||
ограничивает частоту входящих событий.
|
||||
|
||||
Пример вызова:
|
||||
|
||||
```bash
|
||||
|
|
@ -32,10 +35,21 @@ curl -X POST \
|
|||
|
||||
- `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`
|
||||
|
||||
Создает или обновляет активное приглашение для публичной клиентской ссылки, сохраняет
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_AVAILABLE_SLOTS,
|
||||
buildPublicInvitationView,
|
||||
getClientInvitationStateFromOrderStatus,
|
||||
getOrderUpdateForDeliveryInvitationAction,
|
||||
isInvitationExpired,
|
||||
normalizeAvailableSlots,
|
||||
} from "./delivery-invitations";
|
||||
|
||||
|
|
@ -32,4 +34,51 @@ describe("delivery invitation helpers", () => {
|
|||
expect(normalizeAvailableSlots([" Утро ", "", "Вечер", "Утро"])).toEqual(["Утро", "Вечер"]);
|
||||
expect(normalizeAvailableSlots([])).toEqual(DEFAULT_AVAILABLE_SLOTS);
|
||||
});
|
||||
|
||||
it("marks expired and revoked invitations as inactive", () => {
|
||||
expect(
|
||||
isInvitationExpired({
|
||||
order_id: "order-1",
|
||||
token_hash: "token",
|
||||
state: "awaiting_choice",
|
||||
expires_at: "2026-04-01T00:00:00.000Z",
|
||||
}, new Date("2026-04-02T00:00:00.000Z")),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isInvitationExpired({
|
||||
order_id: "order-1",
|
||||
token_hash: "token",
|
||||
state: "awaiting_choice",
|
||||
revoked_at: "2026-04-01T00:00:00.000Z",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("masks customer contact details in the public invitation view", () => {
|
||||
const invitation = buildPublicInvitationView(
|
||||
{
|
||||
order_id: "order-1",
|
||||
token_hash: "token",
|
||||
state: "awaiting_choice",
|
||||
customer_name: "Мария Волкова",
|
||||
customer_phone: "+7 978 123-45-67",
|
||||
order_number: "CD-240031",
|
||||
available_slots: ["2026-04-15, До обеда"],
|
||||
},
|
||||
{
|
||||
order_number: "CD-240031",
|
||||
customer: {
|
||||
name: "Мария Волкова",
|
||||
phone: "+7 978 123-45-67",
|
||||
items: [{ name: "Кухонный гарнитур", quantity: "1 комплект" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(invitation.customerName).toBe("Мария В.");
|
||||
expect(invitation.customerPhone).toContain("***");
|
||||
expect(invitation.orderStatus).toBeNull();
|
||||
expect(invitation.deliveryAgreementStatus).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
import {
|
||||
maskCustomerName,
|
||||
maskPhoneNumber,
|
||||
} from "./security.ts";
|
||||
|
||||
export type DeliveryInvitationAction =
|
||||
| "create_delivery_invitation"
|
||||
| "send_delivery_offer"
|
||||
|
|
@ -108,3 +113,68 @@ export const resolvePublicAppUrl = (
|
|||
|
||||
export const buildInvitationUrl = (baseUrl: string, token: string) =>
|
||||
`${baseUrl.replace(/\/$/, "")}/delivery/${token}`;
|
||||
|
||||
export type DeliveryInvitationRecord = {
|
||||
id?: string;
|
||||
order_id: string;
|
||||
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 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();
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ type IntegrationEventPayload = {
|
|||
export const insertIntegrationEvent = async (
|
||||
supabase: {
|
||||
from: (table: string) => {
|
||||
insert: (payload: IntegrationEventPayload) => Promise<{ error: Error | null }>;
|
||||
insert: (payload: IntegrationEventPayload) => PromiseLike<{ error: Error | null }>;
|
||||
};
|
||||
},
|
||||
payload: IntegrationEventPayload,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,370 @@
|
|||
type CorsMode = "public" | "integration" | "webhook";
|
||||
|
||||
type JsonBodyOptions = {
|
||||
maxBytes: number;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
type RateLimitOptions = {
|
||||
scope: string;
|
||||
key: string;
|
||||
maxCount: number;
|
||||
windowSeconds: 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));
|
||||
}
|
||||
|
||||
const currentMode = readEnv("NODE_ENV") || "development";
|
||||
if (currentMode === "production") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...DEFAULT_LOCAL_ORIGINS];
|
||||
};
|
||||
|
||||
export class HttpError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
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 const getCorsHeaders = (request: Request, mode: CorsMode) => {
|
||||
const origin = getRequestOrigin(request);
|
||||
const allowedOrigins = resolveAllowedOrigins(mode);
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
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 requireRateLimit = async (
|
||||
supabase: {
|
||||
rpc: (
|
||||
name: string,
|
||||
params: Record<string, unknown>,
|
||||
) => PromiseLike<{ data: RateLimitResult | null; error: Error | null }>;
|
||||
},
|
||||
options: RateLimitOptions,
|
||||
) => {
|
||||
const { data, error } = await supabase.rpc("check_rate_limit", {
|
||||
p_scope: options.scope,
|
||||
p_key: options.key,
|
||||
p_max_count: options.maxCount,
|
||||
p_window_seconds: options.windowSeconds,
|
||||
p_block_seconds: options.blockSeconds || 0,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!data?.allowed) {
|
||||
throw new HttpError(429, "Too many requests");
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
|
@ -6,31 +6,69 @@ import {
|
|||
orderUpdateByAction,
|
||||
type ProviderName,
|
||||
} from "../_shared/chatbot.ts";
|
||||
import {
|
||||
getClientIp,
|
||||
getCorsHeaders,
|
||||
hashText,
|
||||
readJsonBody,
|
||||
requireRateLimit,
|
||||
verifyInternalRequest,
|
||||
} from "../_shared/security.ts";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
const MAX_BODY_BYTES = 64 * 1024;
|
||||
|
||||
const allowedProviders = new Set<ProviderName>(["telegram", "vk", "messenger_max"]);
|
||||
|
||||
Deno.serve(async (request) => {
|
||||
if (request.method === "OPTIONS") {
|
||||
return new Response("ok", { headers: corsHeaders });
|
||||
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) {
|
||||
if (!provider || !allowedProviders.has(provider)) {
|
||||
return json({ error: "provider is required" }, 400);
|
||||
}
|
||||
|
||||
const body = (await request.json()) as Record<string, unknown>;
|
||||
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 = {
|
||||
|
|
@ -44,7 +82,7 @@ Deno.serve(async (request) => {
|
|||
};
|
||||
|
||||
const { error: messageError } = await supabase.from("chat_messages").insert(messagePayload);
|
||||
if (messageError) {
|
||||
if (messageError && messageError.code !== "23505") {
|
||||
throw messageError;
|
||||
}
|
||||
|
||||
|
|
@ -89,24 +127,15 @@ Deno.serve(async (request) => {
|
|||
}
|
||||
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers: corsHeaders,
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : "Unexpected error",
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
500,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,49 +2,89 @@ import {
|
|||
getOrderUpdateForDeliveryInvitationAction,
|
||||
hashInvitationToken,
|
||||
isActiveInvitationState,
|
||||
isInvitationExpired,
|
||||
} from "../_shared/delivery-invitations.ts";
|
||||
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 corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
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 new Response("ok", { headers: corsHeaders });
|
||||
return preflightResponse(request, "public");
|
||||
}
|
||||
|
||||
if (request.method !== "POST") {
|
||||
return new Response(JSON.stringify({ error: "Method not allowed" }), {
|
||||
status: 405,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
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 request.json()) as {
|
||||
token?: string;
|
||||
deliveryDate?: string;
|
||||
deliveryTime?: string;
|
||||
};
|
||||
const { body } = await readJsonBody<ConfirmBody>(request, {
|
||||
maxBytes: MAX_BODY_BYTES,
|
||||
});
|
||||
|
||||
if (!body.token) {
|
||||
return new Response(JSON.stringify({ error: "token is required" }), {
|
||||
status: 400,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
return jsonResponse({ ok: false, error: "token is required" }, 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")
|
||||
|
|
@ -54,21 +94,16 @@ Deno.serve(async (request) => {
|
|||
|
||||
if (invitationError) {
|
||||
if (invitationError.code === "PGRST116") {
|
||||
return new Response(
|
||||
JSON.stringify({ ok: false, error: "Invitation not found" }),
|
||||
{
|
||||
status: 404,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
return jsonResponse({ ok: false, error: "Invitation not found" }, 404, corsHeaders);
|
||||
}
|
||||
|
||||
throw invitationError;
|
||||
}
|
||||
|
||||
if (isInvitationExpired(invitation)) {
|
||||
return jsonResponse({ ok: false, error: "Invitation expired" }, 410, corsHeaders);
|
||||
}
|
||||
|
||||
const { data: currentOrder, error: orderError } = await supabase
|
||||
.from("orders")
|
||||
.select("id, status, delivery_agreement_status")
|
||||
|
|
@ -80,32 +115,39 @@ Deno.serve(async (request) => {
|
|||
}
|
||||
|
||||
if (!isActiveInvitationState(invitation.state) || !["Ожидает ответа клиента", "Ожидает согласования доставки"].includes(currentOrder.status)) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
return jsonResponse(
|
||||
{
|
||||
ok: false,
|
||||
error: "Invitation is no longer active",
|
||||
}),
|
||||
{
|
||||
status: 409,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
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 deliveryDate = body.deliveryDate || new Date().toISOString().slice(0, 10);
|
||||
const deliveryTime = body.deliveryTime || "Первая половина дня";
|
||||
|
||||
const { error: invitationUpdateError } = await supabase
|
||||
.from("delivery_invitations")
|
||||
.update({
|
||||
state: "agreed",
|
||||
delivery_date: deliveryDate,
|
||||
delivery_time: deliveryTime,
|
||||
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);
|
||||
|
||||
|
|
@ -127,8 +169,8 @@ Deno.serve(async (request) => {
|
|||
|
||||
const { error: slotError } = await supabase.from("delivery_slots").insert({
|
||||
order_id: invitation.order_id,
|
||||
delivery_date: deliveryDate,
|
||||
delivery_time: deliveryTime,
|
||||
delivery_date: requestedSlot.deliveryDate,
|
||||
delivery_time: requestedSlot.deliveryTime,
|
||||
logistician_id: null,
|
||||
status: "confirmed_by_client",
|
||||
});
|
||||
|
|
@ -145,8 +187,8 @@ Deno.serve(async (request) => {
|
|||
metadata: {
|
||||
old_delivery_agreement_status: currentOrder.delivery_agreement_status,
|
||||
new_delivery_agreement_status: orderUpdate?.deliveryAgreementStatus,
|
||||
delivery_date: deliveryDate,
|
||||
delivery_time: deliveryTime,
|
||||
delivery_date: requestedSlot.deliveryDate,
|
||||
delivery_time: requestedSlot.deliveryTime,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -160,38 +202,34 @@ Deno.serve(async (request) => {
|
|||
direction: "inbound",
|
||||
status: "success",
|
||||
payload: {
|
||||
delivery_date: deliveryDate,
|
||||
delivery_time: deliveryTime,
|
||||
delivery_date: requestedSlot.deliveryDate,
|
||||
delivery_time: requestedSlot.deliveryTime,
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
return jsonResponse(
|
||||
{
|
||||
ok: true,
|
||||
orderId: invitation.order_id,
|
||||
status: orderUpdate?.status,
|
||||
deliveryAgreementStatus: orderUpdate?.deliveryAgreementStatus,
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
200,
|
||||
corsHeaders,
|
||||
);
|
||||
} catch (error) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
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",
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
500,
|
||||
corsHeaders,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,49 +8,68 @@ import {
|
|||
} 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 corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
const MAX_BODY_BYTES = 16 * 1024;
|
||||
|
||||
type CreateInvitationBody = {
|
||||
orderId?: string;
|
||||
orderNumber?: string;
|
||||
customerName?: string;
|
||||
customerPhone?: string;
|
||||
customerMessenger?: string;
|
||||
availableSlots?: string[];
|
||||
source?: string;
|
||||
};
|
||||
|
||||
Deno.serve(async (request) => {
|
||||
if (request.method === "OPTIONS") {
|
||||
return new Response("ok", { headers: corsHeaders });
|
||||
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 new Response(JSON.stringify({ error: "Method not allowed" }), {
|
||||
status: 405,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
return jsonResponse({ error: "Method not allowed" }, 405);
|
||||
}
|
||||
|
||||
const corsHeaders = getCorsHeaders(request, "integration") || {};
|
||||
|
||||
try {
|
||||
const body = (await request.json()) as {
|
||||
orderId?: string;
|
||||
orderNumber?: string;
|
||||
customerName?: string;
|
||||
customerPhone?: string;
|
||||
customerMessenger?: string;
|
||||
availableSlots?: string[];
|
||||
};
|
||||
const { body, rawBody } = await readJsonBody<CreateInvitationBody>(request, {
|
||||
maxBytes: MAX_BODY_BYTES,
|
||||
});
|
||||
const auth = await verifyInternalRequest(request, rawBody, {
|
||||
rawBody,
|
||||
allowedClockSkewSeconds: 300,
|
||||
});
|
||||
|
||||
if (!body.orderId) {
|
||||
return json({ error: "orderId is required" }, 400);
|
||||
return jsonResponse({ error: "orderId is required" }, 400, corsHeaders);
|
||||
}
|
||||
|
||||
const supabase = createServiceClient();
|
||||
await requireRateLimit(supabase, {
|
||||
scope: "delivery-invitation-create",
|
||||
key: body.orderId,
|
||||
maxCount: 10,
|
||||
windowSeconds: 600,
|
||||
blockSeconds: 1800,
|
||||
});
|
||||
|
||||
const token = generateInvitationToken();
|
||||
const tokenHash = await hashInvitationToken(token);
|
||||
const supabase = createServiceClient();
|
||||
const orderUpdate = getOrderUpdateForDeliveryInvitationAction("create_delivery_invitation");
|
||||
|
||||
const { data: currentOrder, error: orderError } = await supabase
|
||||
.from("orders")
|
||||
.select("id, status, delivery_agreement_status, delivery_flow_started_at")
|
||||
.select("id, status, delivery_agreement_status, ready_for_delivery_at, delivery_flow_started_at")
|
||||
.eq("id", body.orderId)
|
||||
.single();
|
||||
|
||||
|
|
@ -60,7 +79,9 @@ Deno.serve(async (request) => {
|
|||
|
||||
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")
|
||||
.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", body.orderId)
|
||||
.maybeSingle();
|
||||
|
||||
|
|
@ -69,8 +90,8 @@ Deno.serve(async (request) => {
|
|||
}
|
||||
|
||||
if (currentOrder.delivery_flow_started_at || existingInvitation) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
return jsonResponse(
|
||||
{
|
||||
ok: true,
|
||||
alreadyStarted: true,
|
||||
invitation: existingInvitation
|
||||
|
|
@ -87,13 +108,9 @@ Deno.serve(async (request) => {
|
|||
orderId: body.orderId,
|
||||
state: "awaiting_choice",
|
||||
},
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
200,
|
||||
corsHeaders,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -106,6 +123,7 @@ Deno.serve(async (request) => {
|
|||
customer_phone: body.customerPhone || null,
|
||||
customer_messenger: body.customerMessenger || null,
|
||||
available_slots: normalizeAvailableSlots(body.availableSlots),
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
sent_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
|
|
@ -139,6 +157,7 @@ Deno.serve(async (request) => {
|
|||
old_delivery_agreement_status: currentOrder.delivery_agreement_status,
|
||||
new_delivery_agreement_status: orderUpdate?.deliveryAgreementStatus,
|
||||
channel: channelFromProvider("telegram"),
|
||||
auth: auth.authenticatedBy,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -159,8 +178,8 @@ Deno.serve(async (request) => {
|
|||
|
||||
const publicBaseUrl = resolvePublicAppUrl(request);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
return jsonResponse(
|
||||
{
|
||||
ok: true,
|
||||
invitation: {
|
||||
orderId: body.orderId,
|
||||
|
|
@ -169,27 +188,23 @@ Deno.serve(async (request) => {
|
|||
state: "awaiting_choice",
|
||||
availableSlots: invitationPayload.available_slots,
|
||||
},
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
200,
|
||||
corsHeaders,
|
||||
);
|
||||
} catch (error) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
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",
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
500,
|
||||
corsHeaders,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,91 +1,68 @@
|
|||
import {
|
||||
buildPublicInvitationView,
|
||||
getClientInvitationStateFromOrderStatus,
|
||||
hashInvitationToken,
|
||||
isActiveInvitationState,
|
||||
isInvitationExpired,
|
||||
} from "../_shared/delivery-invitations.ts";
|
||||
import { createServiceClient } from "../_shared/chatbot.ts";
|
||||
import {
|
||||
getClientIp,
|
||||
getCorsHeaders,
|
||||
hashText,
|
||||
jsonResponse,
|
||||
preflightResponse,
|
||||
readJsonBody,
|
||||
requireRateLimit,
|
||||
} from "../_shared/security.ts";
|
||||
|
||||
const normalizeOrderItems = (
|
||||
items: unknown,
|
||||
): Array<{ name: string; quantity?: string }> => {
|
||||
if (!Array.isArray(items)) {
|
||||
return [];
|
||||
}
|
||||
const MAX_BODY_BYTES = 8 * 1024;
|
||||
|
||||
return items
|
||||
.map((item) => {
|
||||
if (typeof item === "string") {
|
||||
const [namePart, quantityPart] = item.split("|").map((part) => part.trim());
|
||||
const name = namePart || item.trim();
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return quantityPart ? { name, quantity: quantityPart } : { name };
|
||||
}
|
||||
|
||||
if (item && typeof item === "object") {
|
||||
const typedItem = item as { name?: unknown; quantity?: unknown; label?: unknown };
|
||||
const name = typeof typedItem.name === "string"
|
||||
? typedItem.name.trim()
|
||||
: typeof typedItem.label === "string"
|
||||
? typedItem.label.trim()
|
||||
: "";
|
||||
const quantity = typeof typedItem.quantity === "string"
|
||||
? typedItem.quantity.trim()
|
||||
: typeof typedItem.quantity === "number"
|
||||
? String(typedItem.quantity)
|
||||
: "";
|
||||
|
||||
if (!name && !quantity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return quantity ? { name: name || "Позиция", quantity } : { name: name || "Позиция" };
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
.filter((item): item is { name: string; quantity?: string } => Boolean(item));
|
||||
type InvitationBody = {
|
||||
token?: string;
|
||||
};
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
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 new Response("ok", { headers: corsHeaders });
|
||||
return preflightResponse(request, "public");
|
||||
}
|
||||
|
||||
if (!["GET", "POST"].includes(request.method)) {
|
||||
return new Response(JSON.stringify({ error: "Method not allowed" }), {
|
||||
status: 405,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
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 url = new URL(request.url);
|
||||
const token = request.method === "POST"
|
||||
? ((await request.json()) as { token?: string })?.token || ""
|
||||
: url.searchParams.get("token") || "";
|
||||
const token = await getTokenFromRequest(request);
|
||||
if (!token) {
|
||||
return new Response(JSON.stringify({ error: "token is required" }), {
|
||||
status: 400,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
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")
|
||||
|
|
@ -95,21 +72,16 @@ Deno.serve(async (request) => {
|
|||
|
||||
if (invitationError) {
|
||||
if (invitationError.code === "PGRST116") {
|
||||
return new Response(
|
||||
JSON.stringify({ ok: false, error: "Invitation not found" }),
|
||||
{
|
||||
status: 404,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
return jsonResponse({ ok: false, error: "Invitation not found" }, 404, corsHeaders);
|
||||
}
|
||||
|
||||
throw invitationError;
|
||||
}
|
||||
|
||||
if (isInvitationExpired(invitation)) {
|
||||
return jsonResponse({ ok: false, error: "Invitation expired" }, 410, corsHeaders);
|
||||
}
|
||||
|
||||
const { data: order, error: orderError } = await supabase
|
||||
.from("orders")
|
||||
.select("id, order_number, status, delivery_agreement_status, customer")
|
||||
|
|
@ -127,48 +99,47 @@ Deno.serve(async (request) => {
|
|||
.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);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
const invitationView = buildPublicInvitationView(invitation, order);
|
||||
|
||||
return jsonResponse(
|
||||
{
|
||||
ok: true,
|
||||
invitation: {
|
||||
orderId: invitation.order_id,
|
||||
...invitationView,
|
||||
token,
|
||||
state: publicState,
|
||||
token: token,
|
||||
orderNumber: order.order_number,
|
||||
customerName: order.customer?.name || invitation.customer_name || null,
|
||||
customerPhone: order.customer?.phone || invitation.customer_phone || null,
|
||||
orderItems: normalizeOrderItems(order.customer?.items),
|
||||
availableSlots: invitation.available_slots || [],
|
||||
deliveryDate: invitation.delivery_date || null,
|
||||
deliveryTime: invitation.delivery_time || null,
|
||||
orderStatus: order.status,
|
||||
deliveryAgreementStatus: order.delivery_agreement_status,
|
||||
},
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
200,
|
||||
corsHeaders,
|
||||
);
|
||||
} catch (error) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
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",
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
500,
|
||||
corsHeaders,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,46 +3,54 @@ import {
|
|||
} 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 corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
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") {
|
||||
return new Response("ok", { headers: corsHeaders });
|
||||
const corsHeaders = getCorsHeaders(request, "integration");
|
||||
return corsHeaders ? new Response("ok", { headers: corsHeaders }) : jsonResponse({ error: "Origin not allowed" }, 403);
|
||||
}
|
||||
|
||||
if (request.method !== "POST") {
|
||||
return new Response(JSON.stringify({ error: "Method not allowed" }), {
|
||||
status: 405,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
return jsonResponse({ error: "Method not allowed" }, 405);
|
||||
}
|
||||
|
||||
const corsHeaders = getCorsHeaders(request, "integration") || {};
|
||||
|
||||
try {
|
||||
const body = (await request.json()) as {
|
||||
orderId?: string;
|
||||
result?: "delivered" | "problem";
|
||||
note?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
};
|
||||
const { body, rawBody } = await readJsonBody<ReportBody>(request, {
|
||||
maxBytes: MAX_BODY_BYTES,
|
||||
});
|
||||
await verifyInternalRequest(request, rawBody, { rawBody });
|
||||
|
||||
if (!body.orderId) {
|
||||
return new Response(JSON.stringify({ error: "orderId is required" }), {
|
||||
status: 400,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
return jsonResponse({ error: "orderId is required" }, 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")
|
||||
|
|
@ -114,8 +122,8 @@ Deno.serve(async (request) => {
|
|||
},
|
||||
});
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
return jsonResponse(
|
||||
{
|
||||
ok: true,
|
||||
orderId: body.orderId,
|
||||
status: nextStatus,
|
||||
|
|
@ -123,27 +131,23 @@ Deno.serve(async (request) => {
|
|||
? "Подтверждено клиентом"
|
||||
: body.note || currentOrder.delivery_agreement_status || "Ошибка отправки",
|
||||
workflowStatus: nextStatus,
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
200,
|
||||
corsHeaders,
|
||||
);
|
||||
} catch (error) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
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",
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
500,
|
||||
corsHeaders,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
import { createServiceClient } from "../_shared/chatbot.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());
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: {
|
||||
shouldCreateUser: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return jsonResponse({ ok: false, error: error.message }, 400, 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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -5,6 +5,12 @@ import {
|
|||
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"),
|
||||
|
|
@ -12,6 +18,8 @@ const providerTokens: Record<ProviderName, string | undefined> = {
|
|||
messenger_max: Deno.env.get("MESSENGER_MAX_TOKEN"),
|
||||
};
|
||||
|
||||
const MAX_BODY_BYTES = 16 * 1024;
|
||||
|
||||
const sendToProvider = async ({
|
||||
provider,
|
||||
recipientId,
|
||||
|
|
@ -38,22 +46,41 @@ const sendToProvider = async ({
|
|||
};
|
||||
|
||||
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 = (await request.json()) as {
|
||||
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 supabase = createServiceClient();
|
||||
|
||||
const { error } = await supabase.from("chat_messages").insert({
|
||||
order_id: body.orderId,
|
||||
|
|
|
|||
|
|
@ -3,46 +3,54 @@ import {
|
|||
} 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 corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
const MAX_BODY_BYTES = 16 * 1024;
|
||||
|
||||
type TransferBody = {
|
||||
orderId?: string;
|
||||
reason?: string;
|
||||
note?: string;
|
||||
targetStatus?: "Передан логисту" | "Платное хранение";
|
||||
};
|
||||
|
||||
Deno.serve(async (request) => {
|
||||
if (request.method === "OPTIONS") {
|
||||
return new Response("ok", { headers: corsHeaders });
|
||||
const corsHeaders = getCorsHeaders(request, "integration");
|
||||
return corsHeaders ? new Response("ok", { headers: corsHeaders }) : jsonResponse({ error: "Origin not allowed" }, 403);
|
||||
}
|
||||
|
||||
if (request.method !== "POST") {
|
||||
return new Response(JSON.stringify({ error: "Method not allowed" }), {
|
||||
status: 405,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
return jsonResponse({ error: "Method not allowed" }, 405);
|
||||
}
|
||||
|
||||
const corsHeaders = getCorsHeaders(request, "integration") || {};
|
||||
|
||||
try {
|
||||
const body = (await request.json()) as {
|
||||
orderId?: string;
|
||||
reason?: string;
|
||||
note?: string;
|
||||
targetStatus?: "Передан логисту" | "Платное хранение";
|
||||
};
|
||||
const { body, rawBody } = await readJsonBody<TransferBody>(request, {
|
||||
maxBytes: MAX_BODY_BYTES,
|
||||
});
|
||||
await verifyInternalRequest(request, rawBody, { rawBody });
|
||||
|
||||
if (!body.orderId) {
|
||||
return new Response(JSON.stringify({ error: "orderId is required" }), {
|
||||
status: 400,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
return jsonResponse({ error: "orderId is required" }, 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")
|
||||
|
|
@ -113,33 +121,29 @@ Deno.serve(async (request) => {
|
|||
},
|
||||
});
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
return jsonResponse(
|
||||
{
|
||||
ok: true,
|
||||
orderId: body.orderId,
|
||||
status: orderUpdate?.status,
|
||||
deliveryAgreementStatus: body.note || orderUpdate?.deliveryAgreementStatus,
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
200,
|
||||
corsHeaders,
|
||||
);
|
||||
} catch (error) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
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",
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
500,
|
||||
corsHeaders,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
import { createServiceClient } from "../_shared/chatbot.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());
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
const { data, error } = await supabase.auth.verifyOtp({
|
||||
email,
|
||||
token: otp,
|
||||
type: "email",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return jsonResponse({ ok: false, error: error.message }, 400, corsHeaders);
|
||||
}
|
||||
|
||||
return jsonResponse(
|
||||
{
|
||||
ok: true,
|
||||
session: data.session || null,
|
||||
user: data.session?.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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -95,6 +95,10 @@ create table if not exists public.delivery_invitations (
|
|||
customer_phone text,
|
||||
customer_messenger text,
|
||||
available_slots text[] not null default array['Первая половина дня', 'Вторая половина дня'],
|
||||
expires_at timestamptz,
|
||||
revoked_at timestamptz,
|
||||
access_count integer not null default 0,
|
||||
last_accessed_at timestamptz,
|
||||
delivery_date date,
|
||||
delivery_time text,
|
||||
sent_at timestamptz,
|
||||
|
|
@ -119,6 +123,18 @@ create table if not exists public.integration_events (
|
|||
created_at timestamptz not null default timezone('utc', now())
|
||||
);
|
||||
|
||||
create table if not exists public.rate_limits (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
scope text not null,
|
||||
rate_key text not null,
|
||||
window_start timestamptz not null,
|
||||
count integer not null default 1,
|
||||
blocked_until timestamptz,
|
||||
created_at timestamptz not null default timezone('utc', now()),
|
||||
updated_at timestamptz not null default timezone('utc', now()),
|
||||
unique (scope, rate_key, window_start)
|
||||
);
|
||||
|
||||
alter table public.orders add column if not exists delivery_agreement_status text not null default 'Не начато';
|
||||
alter table public.orders add column if not exists assigned_driver_id uuid references public.users (id);
|
||||
alter table public.orders add column if not exists ready_for_delivery_at timestamptz;
|
||||
|
|
@ -130,6 +146,10 @@ alter table public.chat_messages
|
|||
check (channel in ('telegram', 'vk', 'messenger_max', 'sms', 'email'));
|
||||
|
||||
alter table public.delivery_invitations add column if not exists state text not null default 'awaiting_choice';
|
||||
alter table public.delivery_invitations add column if not exists expires_at timestamptz;
|
||||
alter table public.delivery_invitations add column if not exists revoked_at timestamptz;
|
||||
alter table public.delivery_invitations add column if not exists access_count integer not null default 0;
|
||||
alter table public.delivery_invitations add column if not exists last_accessed_at timestamptz;
|
||||
alter table public.delivery_invitations add column if not exists delivery_date date;
|
||||
alter table public.delivery_invitations add column if not exists delivery_time text;
|
||||
alter table public.delivery_invitations add column if not exists sent_at timestamptz;
|
||||
|
|
@ -172,6 +192,13 @@ alter table public.integration_events add column if not exists source text not n
|
|||
alter table public.integration_events add column if not exists status text not null default 'success';
|
||||
alter table public.integration_events add column if not exists payload jsonb not null default '{}'::jsonb;
|
||||
alter table public.integration_events add column if not exists error_message text;
|
||||
alter table public.rate_limits add column if not exists scope text not null;
|
||||
alter table public.rate_limits add column if not exists rate_key text not null;
|
||||
alter table public.rate_limits add column if not exists window_start timestamptz not null;
|
||||
alter table public.rate_limits add column if not exists count integer not null default 1;
|
||||
alter table public.rate_limits add column if not exists blocked_until timestamptz;
|
||||
alter table public.rate_limits add column if not exists created_at timestamptz not null default timezone('utc', now());
|
||||
alter table public.rate_limits add column if not exists updated_at timestamptz not null default timezone('utc', now());
|
||||
|
||||
create index if not exists idx_orders_delivery_set_key on public.orders (delivery_set_key);
|
||||
create index if not exists idx_orders_delivery_set_status on public.orders (delivery_set_status);
|
||||
|
|
@ -348,8 +375,87 @@ create index if not exists idx_chat_messages_search on public.chat_messages usin
|
|||
create index if not exists idx_delivery_invitations_order_id on public.delivery_invitations (order_id);
|
||||
create index if not exists idx_delivery_invitations_token_hash on public.delivery_invitations (token_hash);
|
||||
create index if not exists idx_delivery_invitations_state on public.delivery_invitations (state);
|
||||
create index if not exists idx_delivery_invitations_expires_at on public.delivery_invitations (expires_at);
|
||||
create index if not exists idx_integration_events_order_id on public.integration_events (order_id, created_at desc);
|
||||
create index if not exists idx_integration_events_event_type on public.integration_events (event_type);
|
||||
create index if not exists idx_rate_limits_scope_key_window on public.rate_limits (scope, rate_key, window_start desc);
|
||||
create index if not exists idx_rate_limits_blocked_until on public.rate_limits (blocked_until);
|
||||
|
||||
create or replace function public.check_rate_limit(
|
||||
p_scope text,
|
||||
p_key text,
|
||||
p_max_count integer,
|
||||
p_window_seconds integer,
|
||||
p_block_seconds integer default 0
|
||||
)
|
||||
returns table (
|
||||
allowed boolean,
|
||||
current_count integer,
|
||||
limit_count integer,
|
||||
blocked_until timestamptz,
|
||||
window_start timestamptz
|
||||
)
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
declare
|
||||
v_now timestamptz := timezone('utc', now());
|
||||
v_window_start timestamptz;
|
||||
v_count integer;
|
||||
v_blocked_until timestamptz;
|
||||
begin
|
||||
if p_max_count <= 0 then
|
||||
raise exception 'max_count must be positive';
|
||||
end if;
|
||||
|
||||
if p_window_seconds <= 0 then
|
||||
raise exception 'window_seconds must be positive';
|
||||
end if;
|
||||
|
||||
v_window_start := to_timestamp(floor(extract(epoch from v_now) / p_window_seconds) * p_window_seconds);
|
||||
|
||||
select rl.blocked_until
|
||||
into v_blocked_until
|
||||
from public.rate_limits rl
|
||||
where rl.scope = p_scope
|
||||
and rl.rate_key = p_key
|
||||
and rl.blocked_until is not null
|
||||
and rl.blocked_until > v_now
|
||||
order by rl.blocked_until desc
|
||||
limit 1;
|
||||
|
||||
if v_blocked_until is not null then
|
||||
return query
|
||||
select false, 0, p_max_count, v_blocked_until, v_window_start;
|
||||
return;
|
||||
end if;
|
||||
|
||||
insert into public.rate_limits (scope, rate_key, window_start, count, blocked_until)
|
||||
values (p_scope, p_key, v_window_start, 1, null)
|
||||
on conflict (scope, rate_key, window_start)
|
||||
do update set
|
||||
count = public.rate_limits.count + 1,
|
||||
blocked_until = case
|
||||
when public.rate_limits.count + 1 > p_max_count and p_block_seconds > 0 then greatest(
|
||||
coalesce(public.rate_limits.blocked_until, v_now),
|
||||
v_now + make_interval(secs => p_block_seconds)
|
||||
)
|
||||
else public.rate_limits.blocked_until
|
||||
end,
|
||||
updated_at = v_now
|
||||
returning count, blocked_until
|
||||
into v_count, v_blocked_until;
|
||||
|
||||
return query
|
||||
select
|
||||
v_count <= p_max_count and (v_blocked_until is null or v_blocked_until <= v_now),
|
||||
v_count,
|
||||
p_max_count,
|
||||
v_blocked_until,
|
||||
v_window_start;
|
||||
end;
|
||||
$$;
|
||||
|
||||
alter table public.roles enable row level security;
|
||||
alter table public.users enable row level security;
|
||||
|
|
@ -386,7 +492,7 @@ using (public.current_role_name() = 'admin');
|
|||
drop policy if exists "users self or admin" on public.users;
|
||||
create policy "users self or admin" on public.users
|
||||
for select
|
||||
using (public.current_role_name() is not null);
|
||||
using (public.current_role_name() = 'admin' or id = auth.uid());
|
||||
|
||||
drop policy if exists "users admin update" on public.users;
|
||||
create policy "users admin update" on public.users
|
||||
|
|
@ -571,6 +677,13 @@ for all
|
|||
using (public.current_role_name() = 'admin')
|
||||
with check (public.current_role_name() = 'admin');
|
||||
|
||||
alter table public.rate_limits enable row level security;
|
||||
drop policy if exists "rate limits admin only" on public.rate_limits;
|
||||
create policy "rate limits admin only" on public.rate_limits
|
||||
for all
|
||||
using (public.current_role_name() = 'admin')
|
||||
with check (public.current_role_name() = 'admin');
|
||||
|
||||
drop policy if exists "integration events admin only" on public.integration_events;
|
||||
create policy "integration events admin only" on public.integration_events
|
||||
for all
|
||||
|
|
|
|||
Loading…
Reference in New Issue