diff --git a/docs/n8n-order-group-delivery-flow.md b/docs/n8n-order-group-delivery-flow.md index 7e8ebc0..763e41d 100644 --- a/docs/n8n-order-group-delivery-flow.md +++ b/docs/n8n-order-group-delivery-flow.md @@ -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 = ` -- `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 = ` -- `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. Убедиться, что потоки напоминаний больше не трогают эту группу. diff --git a/docs/sql/order-groups-auto-delivery-link.sql b/docs/sql/order-groups-auto-delivery-link.sql index 29f9c09..98cc015 100644 --- a/docs/sql/order-groups-auto-delivery-link.sql +++ b/docs/sql/order-groups-auto-delivery-link.sql @@ -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; diff --git a/src/services/deliveryInvitationApi.js b/src/services/deliveryInvitationApi.js index d2b1b24..9619511 100644 --- a/src/services/deliveryInvitationApi.js +++ b/src/services/deliveryInvitationApi.js @@ -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); } diff --git a/src/services/deliveryInvitationApi.test.js b/src/services/deliveryInvitationApi.test.js index 12f7fef..d80794b 100644 --- a/src/services/deliveryInvitationApi.test.js +++ b/src/services/deliveryInvitationApi.test.js @@ -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 () => { diff --git a/src/supabaseClient.js b/src/supabaseClient.js index 4553b3d..20e7775 100644 --- a/src/supabaseClient.js +++ b/src/supabaseClient.js @@ -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); diff --git a/supabase/schema.sql b/supabase/schema.sql index b02c72e..936a947 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -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,