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

View File

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

View File

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

View File

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

View File

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

View File

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