fix(delivery): load links and schedule SMS
This commit is contained in:
parent
b79de7afba
commit
633973142d
|
|
@ -1,211 +1,254 @@
|
||||||
# n8n Flow For `order_groups`
|
# Поток n8n для `order_groups`
|
||||||
|
|
||||||
## Goal
|
## Цель
|
||||||
|
|
||||||
`Supabase` stores the delivery state, generates the client link, and saves all timestamps.
|
`Supabase` хранит состояние доставки, генерирует ссылку для клиента и
|
||||||
`n8n` is responsible for sending SMS, retrying, and moving stalled groups into manual handling.
|
сохраняет все временные отметки.
|
||||||
|
`n8n` отвечает за отправку SMS, повторные попытки и перевод зависших
|
||||||
|
групп в ручную обработку.
|
||||||
|
|
||||||
The short link is not needed on our side. We store the full `delivery_link`, and the SMS provider shortens it during delivery.
|
Короткая ссылка с нашей стороны не нужна. Мы храним полную `delivery_link`,
|
||||||
|
а сервис SMS сам сокращает ссылку при отправке.
|
||||||
|
|
||||||
## Source Of Truth
|
## Источник истины
|
||||||
|
|
||||||
The main record is `public.order_groups`.
|
Основная запись находится в `public.order_groups`.
|
||||||
|
|
||||||
Important fields:
|
Важные поля:
|
||||||
|
|
||||||
- `status` - business readiness of the group
|
- `status` - бизнес-готовность группы
|
||||||
- `delivery_status` - delivery coordination state for the client and logistics
|
- `delivery_status` - состояние согласования доставки для клиента и логиста
|
||||||
- `delivery_link` - full public link to `/delivery/:token`
|
- `delivery_link` - полная публичная ссылка на `/delivery/:token`
|
||||||
- `delivery_invitation_id` - related invitation record
|
- `delivery_invitation_id` - связанная запись приглашения
|
||||||
- `notification_status` - SMS orchestration state for `n8n`
|
- `notification_status` - состояние SMS-оркестрации для `n8n`
|
||||||
- `sms_attempts` - how many SMS attempts were made
|
- `sms_attempts` - сколько было попыток отправки SMS
|
||||||
- `first_sms_sent_at` - timestamp of the first SMS
|
- `first_sms_sent_at` - время первой SMS
|
||||||
- `second_sms_sent_at` - timestamp of the second SMS
|
- `second_sms_sent_at` - время второй SMS
|
||||||
- `last_sms_error` - last provider error text
|
- `last_sms_error` - текст последней ошибки провайдера
|
||||||
- `next_notification_check_at` - when `n8n` should revisit the record
|
- `next_notification_check_at` - когда `n8n` должен вернуться к записи
|
||||||
- `delivery_date` and `delivery_time` - selected slot after client confirmation
|
- `delivery_date` и `delivery_time` - выбранный слот после подтверждения клиентом
|
||||||
|
|
||||||
## Recommended Status Model
|
## Окно отправки SMS
|
||||||
|
|
||||||
|
Чтобы не тревожить клиентов ночью, SMS отправляются только в местное окно:
|
||||||
|
|
||||||
|
```text
|
||||||
|
09:00-20:00 Europe/Simferopol
|
||||||
|
```
|
||||||
|
|
||||||
|
Если запись стала готова к отправке вне этого окна, SMS не отправляется сразу.
|
||||||
|
`Supabase` должен поставить ближайшее разрешенное время в
|
||||||
|
`next_notification_check_at`:
|
||||||
|
|
||||||
|
- до `09:00` - сегодня в `09:00`
|
||||||
|
- с `09:00` до `20:00` - сразу
|
||||||
|
- после `20:00` - завтра в `09:00`
|
||||||
|
|
||||||
|
Для этого в SQL есть helper:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
public.next_order_group_sms_check_at(start_from timestamptz, delay interval)
|
||||||
|
```
|
||||||
|
|
||||||
|
`n8n` не должен сам решать, можно ли сейчас тревожить клиента. Он просто выбирает
|
||||||
|
записи, где `next_notification_check_at <= now()`.
|
||||||
|
|
||||||
|
## Рекомендуемая модель статусов
|
||||||
|
|
||||||
### `delivery_status`
|
### `delivery_status`
|
||||||
|
|
||||||
- `pending_confirmation` - client has not selected a slot yet
|
- `pending_confirmation` - клиент еще не выбрал слот
|
||||||
- `agreed` - client selected a delivery slot
|
- `agreed` - клиент выбрал слот доставки
|
||||||
- `manual_confirmation_required` - automatic flow failed, manager/logistics must continue manually
|
- `manual_confirmation_required` - автоматический поток не сработал,
|
||||||
- `assigned_to_driver` - delivery is approved and handed over to driver planning
|
менеджер/логист должен продолжить вручную
|
||||||
- `out_for_delivery` - driver is already working on it
|
- `no_contact` - менеджер/логист пытался связаться вручную, но связи с клиентом нет
|
||||||
- `delivered` - delivery completed
|
- `assigned_to_driver` - доставка согласована и передана в планирование водителю
|
||||||
- `cancelled` - group should no longer be processed
|
- `out_for_delivery` - водитель уже в работе
|
||||||
|
- `delivered` - доставка завершена
|
||||||
|
- `cancelled` - группу больше не нужно обрабатывать
|
||||||
|
|
||||||
### `notification_status`
|
### `notification_status`
|
||||||
|
|
||||||
- `not_started` - link has not been prepared yet
|
- `not_started` - ссылка еще не подготовлена
|
||||||
- `link_ready` - Supabase created `delivery_link`, `n8n` can send the first SMS
|
- `link_ready` - `Supabase` создал `delivery_link`, `n8n` может отправлять первую SMS
|
||||||
- `first_sms_sent` - first SMS was accepted by provider
|
- `first_sms_sent` - первая SMS принята провайдером
|
||||||
- `second_sms_sent` - reminder SMS was accepted by provider
|
- `second_sms_sent` - отправлено напоминание
|
||||||
- `confirmed` - client selected a slot
|
- `confirmed` - клиент выбрал слот
|
||||||
- `manual_required` - no confirmation after retries
|
- `manual_required` - после повторов подтверждения нет
|
||||||
- `send_failed` - provider/API error, retry allowed
|
- `send_failed` - ошибка провайдера/API, можно повторить
|
||||||
|
|
||||||
## Supabase Responsibilities
|
## Ответственность Supabase
|
||||||
|
|
||||||
### 1. Prepare the link
|
### 1. Подготовка ссылки
|
||||||
|
|
||||||
When an `order_group` is moved into the client-delivery flow:
|
Когда `order_group` попадает в поток согласования доставки:
|
||||||
|
|
||||||
- `status = 'ready_for_notification'`
|
- `status = 'ready_for_notification'`
|
||||||
- `delivery_status = 'pending_confirmation'`
|
- `delivery_status = 'pending_confirmation'`
|
||||||
|
|
||||||
Supabase trigger `order_groups_ensure_delivery_link` should:
|
Триггер Supabase `order_groups_ensure_delivery_link` должен:
|
||||||
|
|
||||||
- create `delivery_invitations` row with `order_group_id`
|
- создать запись `delivery_invitations` с `order_group_id`
|
||||||
- generate token and full public URL
|
- сгенерировать token и полную публичную ссылку
|
||||||
- write `delivery_link` into `order_groups`
|
- подготовить слоты только на завтра и послезавтра
|
||||||
- set `delivery_invitation_id`
|
- записать `delivery_link` в `order_groups`
|
||||||
- set `notification_status = 'link_ready'`
|
- установить `delivery_invitation_id`
|
||||||
- set `next_notification_check_at = now()`
|
- установить `notification_status = 'link_ready'`
|
||||||
|
- установить `next_notification_check_at` через `public.next_order_group_sms_check_at()`
|
||||||
|
|
||||||
The SQL for this trigger lives in:
|
SQL для этого триггера лежит здесь:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
docs/sql/order-groups-auto-delivery-link.sql
|
docs/sql/order-groups-auto-delivery-link.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
`n8n` no longer has to call `create-delivery-invitation` for `order_groups`. It should wait until the row already has `notification_status = 'link_ready'` and `delivery_link is not null`.
|
`n8n` больше не должен вызывать `create-delivery-invitation` для `order_groups`.
|
||||||
|
Ему нужно ждать, пока в строке уже будут
|
||||||
|
`notification_status = 'link_ready'` и `delivery_link is not null`.
|
||||||
|
|
||||||
### 2. Accept client choice
|
### 2. Прием выбора клиента
|
||||||
|
|
||||||
The public client page uses the token.
|
Публичная страница использует token.
|
||||||
When the client confirms a slot, `confirm-delivery-choice` should:
|
Когда клиент подтверждает слот, `confirm-delivery-choice` должен:
|
||||||
|
|
||||||
- store `delivery_date` and `delivery_time`
|
- сохранить `delivery_date` и `delivery_time`
|
||||||
- set `delivery_status = 'agreed'`
|
- установить `delivery_status = 'agreed'`
|
||||||
- set `notification_status = 'confirmed'`
|
- установить `notification_status = 'confirmed'`
|
||||||
|
|
||||||
That change becomes the stop signal for all reminder workflows in `n8n`.
|
Это становится стоп-сигналом для всех напоминаний в `n8n`.
|
||||||
|
|
||||||
## n8n Workflows
|
## Потоки n8n
|
||||||
|
|
||||||
### Workflow 1. First SMS sender
|
### Поток 1. Отправка первой SMS
|
||||||
|
|
||||||
Trigger:
|
Триггер:
|
||||||
|
|
||||||
- Cron every 5-10 minutes
|
- Cron каждые 5-10 минут
|
||||||
- Optional backup webhook trigger if you later want push-based start
|
- Опционально запасной webhook-триггер, если потом захочешь push-старт
|
||||||
|
|
||||||
Query:
|
Запрос:
|
||||||
|
|
||||||
- `status = 'ready_for_notification'`
|
- `status = 'ready_for_notification'`
|
||||||
- `delivery_status = 'pending_confirmation'`
|
- `delivery_status = 'pending_confirmation'`
|
||||||
- `notification_status = 'link_ready'`
|
- `notification_status = 'link_ready'`
|
||||||
- `delivery_link is not null`
|
- `delivery_link is not null`
|
||||||
|
- `next_notification_check_at <= now()`
|
||||||
|
|
||||||
Action:
|
Действие:
|
||||||
|
|
||||||
- send SMS with `delivery_link`
|
- отправить SMS с `delivery_link`
|
||||||
|
|
||||||
On success update `order_groups`:
|
При успехе обновить `order_groups`:
|
||||||
|
|
||||||
- `notification_status = 'first_sms_sent'`
|
- `notification_status = 'first_sms_sent'`
|
||||||
- `sms_attempts = 1`
|
- `sms_attempts = 1`
|
||||||
- `first_sms_sent_at = now()`
|
- `first_sms_sent_at = now()`
|
||||||
- `sms_sent_at = now()`
|
- `sms_sent_at = now()`
|
||||||
- `last_sms_error = null`
|
- `last_sms_error = null`
|
||||||
- `next_notification_check_at = now() + interval '1 hour'`
|
- `next_notification_check_at = public.next_order_group_sms_check_at(now(), interval '3 hours')`
|
||||||
|
|
||||||
On failure update `order_groups`:
|
Важно: если первая SMS ушла, например, в `18:30`, проверка через 3 часа попала бы
|
||||||
|
на `21:30`. Helper перенесет следующую проверку на завтра `09:00`.
|
||||||
|
|
||||||
|
При ошибке обновить `order_groups`:
|
||||||
|
|
||||||
- `notification_status = 'send_failed'`
|
- `notification_status = 'send_failed'`
|
||||||
- `last_sms_error = <provider error>`
|
- `last_sms_error = <ошибка провайдера>`
|
||||||
- `next_notification_check_at = now() + interval '10 minutes'`
|
- `next_notification_check_at = public.next_order_group_sms_check_at(now(), interval '10 minutes')`
|
||||||
|
|
||||||
## Workflow 2. Delivery watchdog
|
### Поток 2. Наблюдатель доставки
|
||||||
|
|
||||||
Trigger:
|
Триггер:
|
||||||
|
|
||||||
- Cron every 10 minutes
|
- Cron каждые 10 минут
|
||||||
|
|
||||||
Purpose:
|
Назначение:
|
||||||
|
|
||||||
- find records where first workflow did not finish cleanly
|
- найти записи, где первый поток не завершился корректно
|
||||||
- retry failed first sends
|
- повторить неудачные первые отправки
|
||||||
|
|
||||||
Query candidates:
|
Кандидаты для выбора:
|
||||||
|
|
||||||
- `notification_status = 'send_failed'`
|
- `notification_status = 'send_failed'`
|
||||||
- `delivery_status = 'pending_confirmation'`
|
- `delivery_status = 'pending_confirmation'`
|
||||||
- `next_notification_check_at <= now()`
|
- `next_notification_check_at <= now()`
|
||||||
|
|
||||||
Behavior:
|
Поведение:
|
||||||
|
|
||||||
- retry first SMS
|
- повторить первую SMS
|
||||||
- if success, move to `first_sms_sent`
|
- если успех, перевести в `first_sms_sent`
|
||||||
- if repeated failures exceed your chosen threshold, move to `manual_required`
|
- если повторные ошибки превысили выбранный порог, перевести в `manual_required`
|
||||||
|
- все новые значения `next_notification_check_at` считать через
|
||||||
|
`public.next_order_group_sms_check_at(...)`
|
||||||
|
|
||||||
## Workflow 3. Reminder SMS
|
### Поток 3. Напоминание по SMS
|
||||||
|
|
||||||
Trigger:
|
Триггер:
|
||||||
|
|
||||||
- Cron every 10 minutes
|
- Cron каждые 10 минут
|
||||||
|
|
||||||
Query:
|
Запрос:
|
||||||
|
|
||||||
- `delivery_status = 'pending_confirmation'`
|
- `delivery_status = 'pending_confirmation'`
|
||||||
- `notification_status = 'first_sms_sent'`
|
- `notification_status = 'first_sms_sent'`
|
||||||
- `next_notification_check_at <= now()`
|
- `next_notification_check_at <= now()`
|
||||||
|
|
||||||
Action:
|
Действие:
|
||||||
|
|
||||||
- send second SMS reminder with the same `delivery_link`
|
- отправить вторую SMS-напоминалку с той же `delivery_link`
|
||||||
|
|
||||||
On success update:
|
При успехе обновить:
|
||||||
|
|
||||||
- `notification_status = 'second_sms_sent'`
|
- `notification_status = 'second_sms_sent'`
|
||||||
- `sms_attempts = 2`
|
- `sms_attempts = 2`
|
||||||
- `second_sms_sent_at = now()`
|
- `second_sms_sent_at = now()`
|
||||||
- `last_sms_error = null`
|
- `last_sms_error = null`
|
||||||
- `next_notification_check_at = now() + interval '3 hours'`
|
- `next_notification_check_at = public.next_order_group_sms_check_at(now(), interval '3 hours')`
|
||||||
|
|
||||||
On failure update:
|
При ошибке обновить:
|
||||||
|
|
||||||
- `notification_status = 'send_failed'`
|
- `notification_status = 'send_failed'`
|
||||||
- `last_sms_error = <provider error>`
|
- `last_sms_error = <ошибка провайдера>`
|
||||||
- `next_notification_check_at = now() + interval '30 minutes'`
|
- `next_notification_check_at = public.next_order_group_sms_check_at(now(), interval '30 minutes')`
|
||||||
|
|
||||||
## Workflow 4. Manual handoff
|
### Поток 4. Передача в ручную обработку
|
||||||
|
|
||||||
Trigger:
|
Триггер:
|
||||||
|
|
||||||
- Cron every 10 minutes
|
- Cron каждые 10 минут
|
||||||
|
|
||||||
Query:
|
Запрос:
|
||||||
|
|
||||||
- `delivery_status = 'pending_confirmation'`
|
- `delivery_status = 'pending_confirmation'`
|
||||||
- `notification_status = 'second_sms_sent'`
|
- `notification_status = 'second_sms_sent'`
|
||||||
- `next_notification_check_at <= now()`
|
- `next_notification_check_at <= now()`
|
||||||
|
|
||||||
Action:
|
Действие:
|
||||||
|
|
||||||
- stop automatic reminders
|
- остановить автоматические напоминания
|
||||||
- move the group into manual handling
|
- перевести группу в ручную обработку
|
||||||
|
|
||||||
Update:
|
Обновление:
|
||||||
|
|
||||||
- `delivery_status = 'manual_confirmation_required'`
|
- `delivery_status = 'manual_confirmation_required'`
|
||||||
- `notification_status = 'manual_required'`
|
- `notification_status = 'manual_required'`
|
||||||
|
|
||||||
## Workflow 5. Stop conditions
|
После этого автоматические SMS больше не отправляются. В ЛК менеджер/логист
|
||||||
|
должен вручную выбрать дату доставки или поставить результат `no_contact`, если
|
||||||
|
связаться с клиентом не удалось.
|
||||||
|
|
||||||
Every workflow must ignore rows where:
|
### Поток 5. Условия остановки
|
||||||
|
|
||||||
- `delivery_status in ('agreed', 'assigned_to_driver', 'out_for_delivery', 'delivered', 'cancelled')`
|
Каждый поток должен игнорировать строки, где:
|
||||||
|
|
||||||
|
- `delivery_status in ('agreed', 'no_contact', 'assigned_to_driver', 'out_for_delivery', 'delivered', 'cancelled')`
|
||||||
- `notification_status in ('confirmed', 'manual_required')`
|
- `notification_status in ('confirmed', 'manual_required')`
|
||||||
|
|
||||||
This prevents duplicate SMS after the client already responded or the case was handed to a person.
|
Это не дает слать дублирующие SMS после ответа клиента или передачи кейса человеку.
|
||||||
|
|
||||||
## Suggested SMS Text
|
## Рекомендуемый текст SMS
|
||||||
|
|
||||||
Example:
|
Пример:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Ваш заказ готов к согласованию доставки.
|
Ваш заказ готов к согласованию доставки.
|
||||||
|
|
@ -213,7 +256,7 @@ Example:
|
||||||
{{delivery_link}}
|
{{delivery_link}}
|
||||||
```
|
```
|
||||||
|
|
||||||
Reminder:
|
Напоминание:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Напоминаем: нужно выбрать дату и время доставки вашего заказа.
|
Напоминаем: нужно выбрать дату и время доставки вашего заказа.
|
||||||
|
|
@ -221,31 +264,31 @@ Reminder:
|
||||||
{{delivery_link}}
|
{{delivery_link}}
|
||||||
```
|
```
|
||||||
|
|
||||||
## What Frontend Needs
|
## Что нужно фронтенду
|
||||||
|
|
||||||
The frontend public page only needs:
|
Публичной странице нужны только:
|
||||||
|
|
||||||
- token from URL
|
- token из URL
|
||||||
- `get-delivery-invitation`
|
- `get-delivery-invitation`
|
||||||
- `confirm-delivery-choice`
|
- `confirm-delivery-choice`
|
||||||
|
|
||||||
No SMS logic should live in the frontend.
|
Никакой логики SMS на фронтенде быть не должно.
|
||||||
No link generation should live in the frontend.
|
Никакой генерации ссылок на фронтенде быть не должно.
|
||||||
|
|
||||||
## Minimal Rollout Order
|
## Минимальный порядок внедрения
|
||||||
|
|
||||||
1. Deploy updated `Supabase` schema and `docs/sql/order-groups-auto-delivery-link.sql`.
|
1. Развернуть обновленную схему `Supabase` и `docs/sql/order-groups-auto-delivery-link.sql`.
|
||||||
2. Verify that insert/update in `order_groups` writes `delivery_link` and `notification_status = 'link_ready'`.
|
2. Проверить, что insert/update в `order_groups` пишет `delivery_link` и `notification_status = 'link_ready'`.
|
||||||
3. Build `n8n` workflow for first SMS.
|
3. Собрать поток `n8n` для первой SMS.
|
||||||
4. Build `n8n` reminder workflow.
|
4. Собрать поток `n8n` для напоминаний.
|
||||||
5. Build `n8n` manual-handoff workflow.
|
5. Собрать поток `n8n` для ручной передачи.
|
||||||
6. Test full cycle on one real `order_group`.
|
6. Проверить полный цикл на одной реальной записи `order_group`.
|
||||||
|
|
||||||
## Test Scenario
|
## Сценарий проверки
|
||||||
|
|
||||||
1. Mark one `order_group` as ready for client delivery.
|
1. Пометить одну `order_group` как готовую к клиентскому согласованию доставки.
|
||||||
2. Confirm that `delivery_link` appeared in `order_groups` automatically.
|
2. Убедиться, что `delivery_link` появился в `order_groups` автоматически.
|
||||||
4. Let `n8n` send the first SMS.
|
3. Дать `n8n` отправить первую SMS.
|
||||||
5. Open the link and confirm a slot on the client page.
|
4. Открыть ссылку и подтвердить слот на клиентской странице.
|
||||||
6. Confirm that `delivery_status = 'agreed'` and `notification_status = 'confirmed'`.
|
5. Убедиться, что `delivery_status = 'agreed'` и `notification_status = 'confirmed'`.
|
||||||
7. Confirm that reminder workflows no longer touch this group.
|
6. Убедиться, что потоки напоминаний больше не трогают эту группу.
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,39 @@
|
||||||
|
|
||||||
create extension if not exists pgcrypto with schema extensions;
|
create extension if not exists pgcrypto with schema extensions;
|
||||||
|
|
||||||
|
create or replace function public.next_order_group_sms_check_at(
|
||||||
|
start_from timestamptz default now(),
|
||||||
|
delay interval default interval '0 minutes'
|
||||||
|
)
|
||||||
|
returns timestamptz
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_timezone text := 'Europe/Simferopol';
|
||||||
|
v_local_time timestamp;
|
||||||
|
v_local_date date;
|
||||||
|
v_work_start timestamp;
|
||||||
|
v_work_end timestamp;
|
||||||
|
v_candidate timestamp;
|
||||||
|
begin
|
||||||
|
v_local_time := (start_from at time zone v_timezone) + delay;
|
||||||
|
v_local_date := v_local_time::date;
|
||||||
|
v_work_start := v_local_date + time '09:00';
|
||||||
|
v_work_end := v_local_date + time '20:00';
|
||||||
|
|
||||||
|
if v_local_time < v_work_start then
|
||||||
|
v_candidate := v_work_start;
|
||||||
|
elsif v_local_time >= v_work_end then
|
||||||
|
v_candidate := (v_local_date + 1) + time '09:00';
|
||||||
|
else
|
||||||
|
v_candidate := v_local_time;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return v_candidate at time zone v_timezone;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
create or replace function public.build_order_group_default_available_slots(
|
create or replace function public.build_order_group_default_available_slots(
|
||||||
start_from timestamptz default now()
|
start_from timestamptz default now()
|
||||||
)
|
)
|
||||||
|
|
@ -11,15 +44,8 @@ language sql
|
||||||
stable
|
stable
|
||||||
as $$
|
as $$
|
||||||
with candidate_days as (
|
with candidate_days as (
|
||||||
select day::date as delivery_day
|
select ((start_from at time zone 'Europe/Simferopol')::date + offset_days) as delivery_day
|
||||||
from generate_series(
|
from generate_series(1, 2) as offset_days
|
||||||
(start_from at time zone 'Europe/Simferopol')::date + 1,
|
|
||||||
(start_from at time zone 'Europe/Simferopol')::date + 21,
|
|
||||||
interval '1 day'
|
|
||||||
) as day
|
|
||||||
where extract(isodow from day) between 1 and 5
|
|
||||||
order by day
|
|
||||||
limit 5
|
|
||||||
),
|
),
|
||||||
slots as (
|
slots as (
|
||||||
select format('%s, %s', delivery_day, half_day) as slot_name
|
select format('%s, %s', delivery_day, half_day) as slot_name
|
||||||
|
|
@ -132,7 +158,7 @@ begin
|
||||||
delivery_link = v_delivery_link,
|
delivery_link = v_delivery_link,
|
||||||
notification_status = 'link_ready',
|
notification_status = 'link_ready',
|
||||||
last_sms_error = null,
|
last_sms_error = null,
|
||||||
next_notification_check_at = timezone('utc', now()),
|
next_notification_check_at = public.next_order_group_sms_check_at(),
|
||||||
updated_at = timezone('utc', now())
|
updated_at = timezone('utc', now())
|
||||||
where id = new.id;
|
where id = new.id;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,9 @@
|
||||||
import { supabase, hasSupabaseConfig } from "../supabaseClient";
|
import {
|
||||||
|
supabase,
|
||||||
|
hasSupabaseConfig,
|
||||||
|
supabaseAnonKey,
|
||||||
|
supabaseUrl,
|
||||||
|
} from "../supabaseClient";
|
||||||
|
|
||||||
const SHOWCASE_TOKEN = "showcase";
|
const SHOWCASE_TOKEN = "showcase";
|
||||||
const LOCAL_CLIENT_FLOW_TOKEN_PREFIX = "client-flow-";
|
const LOCAL_CLIENT_FLOW_TOKEN_PREFIX = "client-flow-";
|
||||||
|
|
@ -158,6 +163,34 @@ const invokeDeliveryFunction = async (functionName, body) => {
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchDeliveryFunction = async (functionName, params) => {
|
||||||
|
if (!hasSupabaseConfig || !supabaseUrl || !supabaseAnonKey) {
|
||||||
|
throw new Error("Supabase is not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(`${supabaseUrl.replace(/\/$/, "")}/functions/v1/${functionName}`);
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
url.searchParams.set(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
apikey: supabaseAnonKey,
|
||||||
|
Authorization: `Bearer ${supabaseAnonKey}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data?.error || `Request failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
export const __resetLocalDeliveryInvitationCache = () => {
|
export const __resetLocalDeliveryInvitationCache = () => {
|
||||||
localDeliveryInvitationCache.clear();
|
localDeliveryInvitationCache.clear();
|
||||||
};
|
};
|
||||||
|
|
@ -183,7 +216,7 @@ export const fetchDeliveryInvitation = async (token) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await invokeDeliveryFunction("get-delivery-invitation", { token });
|
const response = await fetchDeliveryFunction("get-delivery-invitation", { token });
|
||||||
if (response?.invitation) {
|
if (response?.invitation) {
|
||||||
return cacheInvitation(response.invitation);
|
return cacheInvitation(response.invitation);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const { invoke } = vi.hoisted(() => ({
|
const { fetchMock, invoke } = vi.hoisted(() => ({
|
||||||
|
fetchMock: vi.fn(),
|
||||||
invoke: vi.fn(),
|
invoke: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../supabaseClient", () => ({
|
vi.mock("../supabaseClient", () => ({
|
||||||
hasSupabaseConfig: true,
|
hasSupabaseConfig: true,
|
||||||
|
supabaseAnonKey: "anon-key",
|
||||||
|
supabaseUrl: "https://supa.example.test",
|
||||||
supabase: {
|
supabase: {
|
||||||
functions: {
|
functions: {
|
||||||
invoke,
|
invoke,
|
||||||
|
|
@ -25,7 +28,9 @@ import {
|
||||||
|
|
||||||
describe("deliveryInvitationApi", () => {
|
describe("deliveryInvitationApi", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
fetchMock.mockReset();
|
||||||
invoke.mockReset();
|
invoke.mockReset();
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
__resetLocalDeliveryInvitationCache();
|
__resetLocalDeliveryInvitationCache();
|
||||||
const storage = new Map();
|
const storage = new Map();
|
||||||
vi.stubGlobal("localStorage", {
|
vi.stubGlobal("localStorage", {
|
||||||
|
|
@ -43,15 +48,15 @@ describe("deliveryInvitationApi", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("loads a delivery invitation by token", async () => {
|
it("loads a delivery invitation by token", async () => {
|
||||||
invoke.mockResolvedValueOnce({
|
fetchMock.mockResolvedValueOnce({
|
||||||
data: {
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
invitation: {
|
invitation: {
|
||||||
orderId: "order-1",
|
orderId: "order-1",
|
||||||
token: "token-1",
|
token: "token-1",
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
error: null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(fetchDeliveryInvitation("token-1")).resolves.toEqual({
|
await expect(fetchDeliveryInvitation("token-1")).resolves.toEqual({
|
||||||
|
|
@ -59,11 +64,32 @@ describe("deliveryInvitationApi", () => {
|
||||||
token: "token-1",
|
token: "token-1",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(invoke).toHaveBeenCalledWith("get-delivery-invitation", {
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
body: {
|
expect.objectContaining({
|
||||||
token: "token-1",
|
href: "https://supa.example.test/functions/v1/get-delivery-invitation?token=token-1",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
apikey: "anon-key",
|
||||||
|
Authorization: "Bearer anon-key",
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(invoke).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("throws a readable error when loading invitation fails", async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 405,
|
||||||
|
json: async () => ({
|
||||||
|
ok: false,
|
||||||
|
error: "Method not allowed",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(fetchDeliveryInvitation("token-1")).rejects.toThrow("Method not allowed");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns a local showcase invitation for the preview token", async () => {
|
it("returns a local showcase invitation for the preview token", async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { createClient } from "@supabase/supabase-js";
|
import { createClient } from "@supabase/supabase-js";
|
||||||
|
|
||||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
export const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
export const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
export const hasSupabaseConfig = Boolean(supabaseUrl && supabaseAnonKey);
|
export const hasSupabaseConfig = Boolean(supabaseUrl && supabaseAnonKey);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,38 @@
|
||||||
create extension if not exists pgcrypto;
|
create extension if not exists pgcrypto;
|
||||||
|
|
||||||
|
create or replace function public.next_order_group_sms_check_at(
|
||||||
|
start_from timestamptz default now(),
|
||||||
|
delay interval default interval '0 minutes'
|
||||||
|
)
|
||||||
|
returns timestamptz
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_timezone text := 'Europe/Simferopol';
|
||||||
|
v_local_time timestamp;
|
||||||
|
v_local_date date;
|
||||||
|
v_work_start timestamp;
|
||||||
|
v_work_end timestamp;
|
||||||
|
v_candidate timestamp;
|
||||||
|
begin
|
||||||
|
v_local_time := (start_from at time zone v_timezone) + delay;
|
||||||
|
v_local_date := v_local_time::date;
|
||||||
|
v_work_start := v_local_date + time '09:00';
|
||||||
|
v_work_end := v_local_date + time '20:00';
|
||||||
|
|
||||||
|
if v_local_time < v_work_start then
|
||||||
|
v_candidate := v_work_start;
|
||||||
|
elsif v_local_time >= v_work_end then
|
||||||
|
v_candidate := (v_local_date + 1) + time '09:00';
|
||||||
|
else
|
||||||
|
v_candidate := v_local_time;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return v_candidate at time zone v_timezone;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
create table if not exists public.roles (
|
create table if not exists public.roles (
|
||||||
id uuid primary key default gen_random_uuid(),
|
id uuid primary key default gen_random_uuid(),
|
||||||
name text not null unique,
|
name text not null unique,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue