fix(delivery): load links and schedule SMS

This commit is contained in:
Codex 2026-05-13 09:22:04 +03:00
parent b79de7afba
commit 633973142d
6 changed files with 305 additions and 144 deletions

View File

@ -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. Убедиться, что потоки напоминаний больше не трогают эту группу.

View File

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

View File

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

View File

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

View File

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

View File

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