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.
|
||||
`n8n` is responsible for sending SMS, retrying, and moving stalled groups into manual handling.
|
||||
`Supabase` хранит состояние доставки, генерирует ссылку для клиента и
|
||||
сохраняет все временные отметки.
|
||||
`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
|
||||
- `delivery_status` - delivery coordination state for the client and logistics
|
||||
- `delivery_link` - full public link to `/delivery/:token`
|
||||
- `delivery_invitation_id` - related invitation record
|
||||
- `notification_status` - SMS orchestration state for `n8n`
|
||||
- `sms_attempts` - how many SMS attempts were made
|
||||
- `first_sms_sent_at` - timestamp of the first SMS
|
||||
- `second_sms_sent_at` - timestamp of the second SMS
|
||||
- `last_sms_error` - last provider error text
|
||||
- `next_notification_check_at` - when `n8n` should revisit the record
|
||||
- `delivery_date` and `delivery_time` - selected slot after client confirmation
|
||||
- `status` - бизнес-готовность группы
|
||||
- `delivery_status` - состояние согласования доставки для клиента и логиста
|
||||
- `delivery_link` - полная публичная ссылка на `/delivery/:token`
|
||||
- `delivery_invitation_id` - связанная запись приглашения
|
||||
- `notification_status` - состояние SMS-оркестрации для `n8n`
|
||||
- `sms_attempts` - сколько было попыток отправки SMS
|
||||
- `first_sms_sent_at` - время первой SMS
|
||||
- `second_sms_sent_at` - время второй SMS
|
||||
- `last_sms_error` - текст последней ошибки провайдера
|
||||
- `next_notification_check_at` - когда `n8n` должен вернуться к записи
|
||||
- `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`
|
||||
|
||||
- `pending_confirmation` - client has not selected a slot yet
|
||||
- `agreed` - client selected a delivery slot
|
||||
- `manual_confirmation_required` - automatic flow failed, manager/logistics must continue manually
|
||||
- `assigned_to_driver` - delivery is approved and handed over to driver planning
|
||||
- `out_for_delivery` - driver is already working on it
|
||||
- `delivered` - delivery completed
|
||||
- `cancelled` - group should no longer be processed
|
||||
- `pending_confirmation` - клиент еще не выбрал слот
|
||||
- `agreed` - клиент выбрал слот доставки
|
||||
- `manual_confirmation_required` - автоматический поток не сработал,
|
||||
менеджер/логист должен продолжить вручную
|
||||
- `no_contact` - менеджер/логист пытался связаться вручную, но связи с клиентом нет
|
||||
- `assigned_to_driver` - доставка согласована и передана в планирование водителю
|
||||
- `out_for_delivery` - водитель уже в работе
|
||||
- `delivered` - доставка завершена
|
||||
- `cancelled` - группу больше не нужно обрабатывать
|
||||
|
||||
### `notification_status`
|
||||
|
||||
- `not_started` - link has not been prepared yet
|
||||
- `link_ready` - Supabase created `delivery_link`, `n8n` can send the first SMS
|
||||
- `first_sms_sent` - first SMS was accepted by provider
|
||||
- `second_sms_sent` - reminder SMS was accepted by provider
|
||||
- `confirmed` - client selected a slot
|
||||
- `manual_required` - no confirmation after retries
|
||||
- `send_failed` - provider/API error, retry allowed
|
||||
- `not_started` - ссылка еще не подготовлена
|
||||
- `link_ready` - `Supabase` создал `delivery_link`, `n8n` может отправлять первую SMS
|
||||
- `first_sms_sent` - первая SMS принята провайдером
|
||||
- `second_sms_sent` - отправлено напоминание
|
||||
- `confirmed` - клиент выбрал слот
|
||||
- `manual_required` - после повторов подтверждения нет
|
||||
- `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'`
|
||||
- `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`
|
||||
- generate token and full public URL
|
||||
- write `delivery_link` into `order_groups`
|
||||
- set `delivery_invitation_id`
|
||||
- set `notification_status = 'link_ready'`
|
||||
- set `next_notification_check_at = now()`
|
||||
- создать запись `delivery_invitations` с `order_group_id`
|
||||
- сгенерировать token и полную публичную ссылку
|
||||
- подготовить слоты только на завтра и послезавтра
|
||||
- записать `delivery_link` в `order_groups`
|
||||
- установить `delivery_invitation_id`
|
||||
- установить `notification_status = 'link_ready'`
|
||||
- установить `next_notification_check_at` через `public.next_order_group_sms_check_at()`
|
||||
|
||||
The SQL for this trigger lives in:
|
||||
SQL для этого триггера лежит здесь:
|
||||
|
||||
```text
|
||||
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.
|
||||
When the client confirms a slot, `confirm-delivery-choice` should:
|
||||
Публичная страница использует token.
|
||||
Когда клиент подтверждает слот, `confirm-delivery-choice` должен:
|
||||
|
||||
- store `delivery_date` and `delivery_time`
|
||||
- set `delivery_status = 'agreed'`
|
||||
- set `notification_status = 'confirmed'`
|
||||
- сохранить `delivery_date` и `delivery_time`
|
||||
- установить `delivery_status = 'agreed'`
|
||||
- установить `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
|
||||
- Optional backup webhook trigger if you later want push-based start
|
||||
- Cron каждые 5-10 минут
|
||||
- Опционально запасной webhook-триггер, если потом захочешь push-старт
|
||||
|
||||
Query:
|
||||
Запрос:
|
||||
|
||||
- `status = 'ready_for_notification'`
|
||||
- `delivery_status = 'pending_confirmation'`
|
||||
- `notification_status = 'link_ready'`
|
||||
- `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'`
|
||||
- `sms_attempts = 1`
|
||||
- `first_sms_sent_at = now()`
|
||||
- `sms_sent_at = now()`
|
||||
- `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'`
|
||||
- `last_sms_error = <provider error>`
|
||||
- `next_notification_check_at = now() + interval '10 minutes'`
|
||||
- `last_sms_error = <ошибка провайдера>`
|
||||
- `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'`
|
||||
- `delivery_status = 'pending_confirmation'`
|
||||
- `next_notification_check_at <= now()`
|
||||
|
||||
Behavior:
|
||||
Поведение:
|
||||
|
||||
- retry first SMS
|
||||
- if success, move to `first_sms_sent`
|
||||
- if repeated failures exceed your chosen threshold, move to `manual_required`
|
||||
- повторить первую SMS
|
||||
- если успех, перевести в `first_sms_sent`
|
||||
- если повторные ошибки превысили выбранный порог, перевести в `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'`
|
||||
- `notification_status = 'first_sms_sent'`
|
||||
- `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'`
|
||||
- `sms_attempts = 2`
|
||||
- `second_sms_sent_at = now()`
|
||||
- `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'`
|
||||
- `last_sms_error = <provider error>`
|
||||
- `next_notification_check_at = now() + interval '30 minutes'`
|
||||
- `last_sms_error = <ошибка провайдера>`
|
||||
- `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'`
|
||||
- `notification_status = 'second_sms_sent'`
|
||||
- `next_notification_check_at <= now()`
|
||||
|
||||
Action:
|
||||
Действие:
|
||||
|
||||
- stop automatic reminders
|
||||
- move the group into manual handling
|
||||
- остановить автоматические напоминания
|
||||
- перевести группу в ручную обработку
|
||||
|
||||
Update:
|
||||
Обновление:
|
||||
|
||||
- `delivery_status = 'manual_confirmation_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')`
|
||||
|
||||
This prevents duplicate SMS after the client already responded or the case was handed to a person.
|
||||
Это не дает слать дублирующие SMS после ответа клиента или передачи кейса человеку.
|
||||
|
||||
## Suggested SMS Text
|
||||
## Рекомендуемый текст SMS
|
||||
|
||||
Example:
|
||||
Пример:
|
||||
|
||||
```text
|
||||
Ваш заказ готов к согласованию доставки.
|
||||
|
|
@ -213,7 +256,7 @@ Example:
|
|||
{{delivery_link}}
|
||||
```
|
||||
|
||||
Reminder:
|
||||
Напоминание:
|
||||
|
||||
```text
|
||||
Напоминаем: нужно выбрать дату и время доставки вашего заказа.
|
||||
|
|
@ -221,31 +264,31 @@ Reminder:
|
|||
{{delivery_link}}
|
||||
```
|
||||
|
||||
## What Frontend Needs
|
||||
## Что нужно фронтенду
|
||||
|
||||
The frontend public page only needs:
|
||||
Публичной странице нужны только:
|
||||
|
||||
- token from URL
|
||||
- token из URL
|
||||
- `get-delivery-invitation`
|
||||
- `confirm-delivery-choice`
|
||||
|
||||
No SMS logic should live in the frontend.
|
||||
No link generation should live in the frontend.
|
||||
Никакой логики SMS на фронтенде быть не должно.
|
||||
Никакой генерации ссылок на фронтенде быть не должно.
|
||||
|
||||
## Minimal Rollout Order
|
||||
## Минимальный порядок внедрения
|
||||
|
||||
1. Deploy updated `Supabase` schema and `docs/sql/order-groups-auto-delivery-link.sql`.
|
||||
2. Verify that insert/update in `order_groups` writes `delivery_link` and `notification_status = 'link_ready'`.
|
||||
3. Build `n8n` workflow for first SMS.
|
||||
4. Build `n8n` reminder workflow.
|
||||
5. Build `n8n` manual-handoff workflow.
|
||||
6. Test full cycle on one real `order_group`.
|
||||
1. Развернуть обновленную схему `Supabase` и `docs/sql/order-groups-auto-delivery-link.sql`.
|
||||
2. Проверить, что insert/update в `order_groups` пишет `delivery_link` и `notification_status = 'link_ready'`.
|
||||
3. Собрать поток `n8n` для первой SMS.
|
||||
4. Собрать поток `n8n` для напоминаний.
|
||||
5. Собрать поток `n8n` для ручной передачи.
|
||||
6. Проверить полный цикл на одной реальной записи `order_group`.
|
||||
|
||||
## Test Scenario
|
||||
## Сценарий проверки
|
||||
|
||||
1. Mark one `order_group` as ready for client delivery.
|
||||
2. Confirm that `delivery_link` appeared in `order_groups` automatically.
|
||||
4. Let `n8n` send the first SMS.
|
||||
5. Open the link and confirm a slot on the client page.
|
||||
6. Confirm that `delivery_status = 'agreed'` and `notification_status = 'confirmed'`.
|
||||
7. Confirm that reminder workflows no longer touch this group.
|
||||
1. Пометить одну `order_group` как готовую к клиентскому согласованию доставки.
|
||||
2. Убедиться, что `delivery_link` появился в `order_groups` автоматически.
|
||||
3. Дать `n8n` отправить первую SMS.
|
||||
4. Открыть ссылку и подтвердить слот на клиентской странице.
|
||||
5. Убедиться, что `delivery_status = 'agreed'` и `notification_status = 'confirmed'`.
|
||||
6. Убедиться, что потоки напоминаний больше не трогают эту группу.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,39 @@
|
|||
|
||||
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(
|
||||
start_from timestamptz default now()
|
||||
)
|
||||
|
|
@ -11,15 +44,8 @@ language sql
|
|||
stable
|
||||
as $$
|
||||
with candidate_days as (
|
||||
select day::date as delivery_day
|
||||
from generate_series(
|
||||
(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
|
||||
select ((start_from at time zone 'Europe/Simferopol')::date + offset_days) as delivery_day
|
||||
from generate_series(1, 2) as offset_days
|
||||
),
|
||||
slots as (
|
||||
select format('%s, %s', delivery_day, half_day) as slot_name
|
||||
|
|
@ -132,7 +158,7 @@ begin
|
|||
delivery_link = v_delivery_link,
|
||||
notification_status = 'link_ready',
|
||||
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())
|
||||
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 LOCAL_CLIENT_FLOW_TOKEN_PREFIX = "client-flow-";
|
||||
|
|
@ -158,6 +163,34 @@ const invokeDeliveryFunction = async (functionName, body) => {
|
|||
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 = () => {
|
||||
localDeliveryInvitationCache.clear();
|
||||
};
|
||||
|
|
@ -183,7 +216,7 @@ export const fetchDeliveryInvitation = async (token) => {
|
|||
}
|
||||
|
||||
try {
|
||||
const response = await invokeDeliveryFunction("get-delivery-invitation", { token });
|
||||
const response = await fetchDeliveryFunction("get-delivery-invitation", { token });
|
||||
if (response?.invitation) {
|
||||
return cacheInvitation(response.invitation);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { invoke } = vi.hoisted(() => ({
|
||||
const { fetchMock, invoke } = vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../supabaseClient", () => ({
|
||||
hasSupabaseConfig: true,
|
||||
supabaseAnonKey: "anon-key",
|
||||
supabaseUrl: "https://supa.example.test",
|
||||
supabase: {
|
||||
functions: {
|
||||
invoke,
|
||||
|
|
@ -25,7 +28,9 @@ import {
|
|||
|
||||
describe("deliveryInvitationApi", () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
invoke.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
__resetLocalDeliveryInvitationCache();
|
||||
const storage = new Map();
|
||||
vi.stubGlobal("localStorage", {
|
||||
|
|
@ -43,15 +48,15 @@ describe("deliveryInvitationApi", () => {
|
|||
});
|
||||
|
||||
it("loads a delivery invitation by token", async () => {
|
||||
invoke.mockResolvedValueOnce({
|
||||
data: {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
ok: true,
|
||||
invitation: {
|
||||
orderId: "order-1",
|
||||
token: "token-1",
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(fetchDeliveryInvitation("token-1")).resolves.toEqual({
|
||||
|
|
@ -59,11 +64,32 @@ describe("deliveryInvitationApi", () => {
|
|||
token: "token-1",
|
||||
});
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("get-delivery-invitation", {
|
||||
body: {
|
||||
token: "token-1",
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||
export const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||
export const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||
|
||||
export const hasSupabaseConfig = Boolean(supabaseUrl && supabaseAnonKey);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,38 @@
|
|||
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 (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
name text not null unique,
|
||||
|
|
|
|||
Loading…
Reference in New Issue