From eeb26205479d6880f746bba49724f842f81400f0 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 13 May 2026 18:59:00 +0300 Subject: [PATCH] fix(delivery): switch public flow to rpc --- docs/sql/public-delivery-choice-rpc.sql | 389 +++++++++++++++++++++ src/pages/ClientDeliveryPage.jsx | 4 + src/services/deliveryInvitationApi.js | 40 +-- src/services/deliveryInvitationApi.test.js | 54 ++- supabase/schema.sql | 388 ++++++++++++++++++++ 5 files changed, 818 insertions(+), 57 deletions(-) create mode 100644 docs/sql/public-delivery-choice-rpc.sql diff --git a/docs/sql/public-delivery-choice-rpc.sql b/docs/sql/public-delivery-choice-rpc.sql new file mode 100644 index 0000000..709f6fc --- /dev/null +++ b/docs/sql/public-delivery-choice-rpc.sql @@ -0,0 +1,389 @@ +create extension if not exists pgcrypto; + +create or replace function public.get_delivery_invitation_by_token(p_token text) +returns jsonb +language plpgsql +security definer +set search_path = public, extensions +as $$ +declare + v_invitation public.delivery_invitations%rowtype; + v_group public.order_groups%rowtype; + v_order record; + v_token_hash text; + v_state text; + v_order_number text; + v_customer_name text; + v_customer_phone text; + v_order_items jsonb; + v_order_numbers jsonb; + v_now timestamptz := timezone('utc', now()); +begin + if nullif(trim(coalesce(p_token, '')), '') is null then + raise exception 'token is required'; + end if; + + v_token_hash := encode(digest(p_token, 'sha256'), 'hex'); + + select * + into v_invitation + from public.delivery_invitations + where token_hash = v_token_hash; + + if not found then + raise exception 'Invitation not found'; + end if; + + if v_invitation.revoked_at is not null then + raise exception 'Invitation expired'; + end if; + + if v_invitation.expires_at is not null and v_invitation.expires_at <= v_now then + raise exception 'Invitation expired'; + end if; + + if v_invitation.order_group_id is not null then + select * + into v_group + from public.order_groups + where id = v_invitation.order_group_id; + + if not found then + raise exception 'Order group not found'; + end if; + + v_state := case + when v_group.delivery_status = 'agreed' then 'agreed' + when v_group.delivery_status = 'delivered' then 'delivered' + when v_invitation.state in ('awaiting_choice', 'opened', 'reminder_sent') then v_invitation.state + else 'default' + end; + + update public.delivery_invitations + set + opened_at = case + when v_state in ('awaiting_choice', 'opened', 'reminder_sent') and opened_at is null then v_now + else opened_at + end, + access_count = coalesce(access_count, 0) + 1, + last_accessed_at = v_now + where id = v_invitation.id + returning * into v_invitation; + + v_order_number := coalesce( + nullif(v_invitation.order_number, ''), + nullif(v_group.group_key, ''), + to_jsonb(v_group.order_numbers) ->> 0 + ); + v_customer_name := coalesce( + nullif(v_group.customer_name, ''), + nullif(v_group.customer ->> 'name', ''), + nullif(v_invitation.customer_name, '') + ); + v_customer_phone := coalesce( + nullif(v_group.customer_phone, ''), + nullif(v_group.customer ->> 'phone', ''), + nullif(v_invitation.customer_phone, '') + ); + select coalesce( + jsonb_agg(jsonb_build_object('name', order_number, 'quantity', '')), + '[]'::jsonb + ) + into v_order_items + from jsonb_array_elements_text( + case + when jsonb_typeof(to_jsonb(v_group.order_numbers)) = 'array' then to_jsonb(v_group.order_numbers) + else '[]'::jsonb + end + ) as order_number; + + return jsonb_build_object( + 'ok', true, + 'invitation', jsonb_build_object( + 'orderId', coalesce(v_invitation.order_group_id, v_group.id)::text, + 'orderGroupId', coalesce(v_invitation.order_group_id, v_group.id)::text, + 'state', v_state, + 'token', p_token, + 'orderNumber', v_order_number, + 'customerName', v_customer_name, + 'customerPhone', v_customer_phone, + 'orderItems', v_order_items, + 'availableSlots', coalesce(to_jsonb(v_invitation.available_slots), '[]'::jsonb), + 'deliveryDate', v_invitation.delivery_date, + 'deliveryTime', v_invitation.delivery_time, + 'orderStatus', null, + 'deliveryAgreementStatus', null + ) + ); + end if; + + select id, order_number, status, delivery_agreement_status, customer + into v_order + from public.orders + where id = v_invitation.order_id; + + if not found then + raise exception 'Order not found'; + end if; + + v_state := case v_order.status + when 'Ожидает ответа клиента' then 'awaiting_choice' + when 'Ожидает согласования доставки' then 'opened' + when 'Напоминание отправлено' then 'reminder_sent' + when 'Переход отправлен' then 'reminder_sent' + when 'Передан логисту' then 'transferred_to_logistics' + when 'Платное хранение' then 'paid_storage' + when 'Доставлен' then 'delivered' + when 'Доставка согласована' then 'agreed' + else 'default' + end; + + update public.delivery_invitations + set + opened_at = case + when v_state in ('awaiting_choice', 'opened', 'reminder_sent') and opened_at is null then v_now + else opened_at + end, + access_count = coalesce(access_count, 0) + 1, + last_accessed_at = v_now + where id = v_invitation.id + returning * into v_invitation; + + v_order_items := case + when jsonb_typeof(v_order.customer -> 'items') = 'array' then v_order.customer -> 'items' + else '[]'::jsonb + end; + + return jsonb_build_object( + 'ok', true, + 'invitation', jsonb_build_object( + 'orderId', v_invitation.order_id::text, + 'state', v_state, + 'token', p_token, + 'orderNumber', coalesce(nullif(v_order.order_number, ''), nullif(v_invitation.order_number, '')), + 'customerName', coalesce(nullif(v_order.customer ->> 'name', ''), nullif(v_invitation.customer_name, '')), + 'customerPhone', coalesce(nullif(v_order.customer ->> 'phone', ''), nullif(v_invitation.customer_phone, '')), + 'orderItems', v_order_items, + 'availableSlots', coalesce(to_jsonb(v_invitation.available_slots), '[]'::jsonb), + 'deliveryDate', v_invitation.delivery_date, + 'deliveryTime', v_invitation.delivery_time, + 'orderStatus', v_order.status, + 'deliveryAgreementStatus', v_order.delivery_agreement_status + ) + ); +end; +$$; + +create or replace function public.confirm_delivery_choice_by_token( + p_token text, + p_delivery_date date, + p_delivery_time text +) +returns jsonb +language plpgsql +security definer +set search_path = public, extensions +as $$ +declare + v_invitation public.delivery_invitations%rowtype; + v_group public.order_groups%rowtype; + v_order record; + v_token_hash text; + v_slot_label text; + v_now timestamptz := timezone('utc', now()); +begin + if nullif(trim(coalesce(p_token, '')), '') is null then + raise exception 'token is required'; + end if; + + if p_delivery_date is null or nullif(trim(coalesce(p_delivery_time, '')), '') is null then + raise exception 'Selected slot is not available'; + end if; + + v_token_hash := encode(digest(p_token, 'sha256'), 'hex'); + v_slot_label := concat(p_delivery_date::text, ', ', trim(p_delivery_time)); + + select * + into v_invitation + from public.delivery_invitations + where token_hash = v_token_hash + for update; + + if not found then + raise exception 'Invitation not found'; + end if; + + if v_invitation.revoked_at is not null then + raise exception 'Invitation expired'; + end if; + + if v_invitation.expires_at is not null and v_invitation.expires_at <= v_now then + raise exception 'Invitation expired'; + end if; + + if v_invitation.state not in ('awaiting_choice', 'opened', 'reminder_sent') then + raise exception 'Invitation is no longer active'; + end if; + + if cardinality(v_invitation.available_slots) > 0 and not (v_slot_label = any(v_invitation.available_slots)) then + raise exception 'Selected slot is not available'; + end if; + + if v_invitation.order_group_id is not null then + select * + into v_group + from public.order_groups + where id = v_invitation.order_group_id + for update; + + if not found then + raise exception 'Order group not found'; + end if; + + if v_group.delivery_status <> 'pending_confirmation' then + raise exception 'Invitation is no longer active'; + end if; + + update public.delivery_invitations + set + state = 'agreed', + delivery_date = p_delivery_date, + delivery_time = trim(p_delivery_time), + confirmed_at = v_now, + access_count = coalesce(access_count, 0) + 1, + last_accessed_at = v_now + where id = v_invitation.id; + + update public.order_groups + set + delivery_status = 'agreed', + delivery_date = p_delivery_date, + delivery_time = trim(p_delivery_time), + notification_status = 'confirmed', + updated_at = v_now + where id = v_group.id; + + insert into public.integration_events ( + order_id, + event_type, + direction, + status, + payload + ) + values ( + null, + 'delivery_choice_confirmed', + 'inbound', + 'success', + jsonb_build_object( + 'order_group_id', v_group.id, + 'delivery_invitation_id', v_invitation.id, + 'delivery_date', p_delivery_date, + 'delivery_time', trim(p_delivery_time) + ) + ); + + return jsonb_build_object( + 'ok', true, + 'orderGroupId', v_group.id, + 'deliveryStatus', 'agreed' + ); + end if; + + select id, status, delivery_agreement_status + into v_order + from public.orders + where id = v_invitation.order_id + for update; + + if not found then + raise exception 'Order not found'; + end if; + + if v_order.status not in ('Ожидает ответа клиента', 'Ожидает согласования доставки') then + raise exception 'Invitation is no longer active'; + end if; + + update public.delivery_invitations + set + state = 'agreed', + delivery_date = p_delivery_date, + delivery_time = trim(p_delivery_time), + confirmed_at = v_now, + access_count = coalesce(access_count, 0) + 1, + last_accessed_at = v_now + where id = v_invitation.id; + + update public.orders + set + status = 'Доставка согласована', + delivery_agreement_status = 'Подтверждено клиентом' + where id = v_order.id; + + insert into public.delivery_slots ( + order_id, + delivery_date, + delivery_time, + logistician_id, + status + ) + values ( + v_order.id, + p_delivery_date, + trim(p_delivery_time), + null, + 'confirmed_by_client' + ); + + insert into public.order_history ( + order_id, + action, + old_status, + new_status, + metadata + ) + values ( + v_order.id, + 'Подтверждение выбора доставки клиентом', + v_order.status, + 'Доставка согласована', + jsonb_build_object( + 'old_delivery_agreement_status', v_order.delivery_agreement_status, + 'new_delivery_agreement_status', 'Подтверждено клиентом', + 'delivery_date', p_delivery_date, + 'delivery_time', trim(p_delivery_time) + ) + ); + + insert into public.integration_events ( + order_id, + event_type, + direction, + status, + payload + ) + values ( + v_order.id, + 'delivery_choice_confirmed', + 'inbound', + 'success', + jsonb_build_object( + 'delivery_date', p_delivery_date, + 'delivery_time', trim(p_delivery_time) + ) + ); + + return jsonb_build_object( + 'ok', true, + 'orderId', v_order.id, + 'status', 'Доставка согласована', + 'deliveryAgreementStatus', 'Подтверждено клиентом' + ); +end; +$$; + +revoke all on function public.get_delivery_invitation_by_token(text) from public; +grant execute on function public.get_delivery_invitation_by_token(text) to anon, authenticated; + +revoke all on function public.confirm_delivery_choice_by_token(text, date, text) from public; +grant execute on function public.confirm_delivery_choice_by_token(text, date, text) to anon, authenticated; diff --git a/src/pages/ClientDeliveryPage.jsx b/src/pages/ClientDeliveryPage.jsx index 9140cf5..76e9be0 100644 --- a/src/pages/ClientDeliveryPage.jsx +++ b/src/pages/ClientDeliveryPage.jsx @@ -311,6 +311,10 @@ export const ClientDeliveryPage = () => {

Ваш выбор

Сохранено: {savedChoiceLabel}

+

+ Заказ {invitation?.orderNumber || "—"} + {invitation?.customerName ? ` · ${invitation.customerName}` : ""} +

Статус: доставка уже согласована. При повторном открытии этой ссылки будет показан тот же выбор.

diff --git a/src/services/deliveryInvitationApi.js b/src/services/deliveryInvitationApi.js index 9619511..d8077c7 100644 --- a/src/services/deliveryInvitationApi.js +++ b/src/services/deliveryInvitationApi.js @@ -1,8 +1,6 @@ import { supabase, hasSupabaseConfig, - supabaseAnonKey, - supabaseUrl, } from "../supabaseClient"; const SHOWCASE_TOKEN = "showcase"; @@ -163,29 +161,19 @@ const invokeDeliveryFunction = async (functionName, body) => { return data; }; -const fetchDeliveryFunction = async (functionName, params) => { - if (!hasSupabaseConfig || !supabaseUrl || !supabaseAnonKey) { +const callDeliveryRpc = async (functionName, params) => { + if (!hasSupabaseConfig || !supabase?.rpc) { 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 { data, error } = await supabase.rpc(functionName, params); - const response = await fetch(url, { - method: "GET", - headers: { - apikey: supabaseAnonKey, - Authorization: `Bearer ${supabaseAnonKey}`, - }, - }); - const data = await response.json().catch(() => null); + if (error) { + throw error; + } - if (!response.ok) { - throw new Error(data?.error || `Request failed with status ${response.status}`); + if (data && data.ok === false) { + throw new Error(data.error || "Request failed"); } return data; @@ -216,7 +204,9 @@ export const fetchDeliveryInvitation = async (token) => { } try { - const response = await fetchDeliveryFunction("get-delivery-invitation", { token }); + const response = await callDeliveryRpc("get_delivery_invitation_by_token", { + p_token: token, + }); if (response?.invitation) { return cacheInvitation(response.invitation); } @@ -248,10 +238,10 @@ export const confirmDeliveryChoice = async ({ token, deliveryDate, deliveryTime return { ok: true, invitation }; } - return invokeDeliveryFunction("confirm-delivery-choice", { - token, - deliveryDate, - deliveryTime, + return callDeliveryRpc("confirm_delivery_choice_by_token", { + p_token: token, + p_delivery_date: deliveryDate, + p_delivery_time: deliveryTime, }); }; diff --git a/src/services/deliveryInvitationApi.test.js b/src/services/deliveryInvitationApi.test.js index d80794b..99086ee 100644 --- a/src/services/deliveryInvitationApi.test.js +++ b/src/services/deliveryInvitationApi.test.js @@ -1,8 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const { fetchMock, invoke } = vi.hoisted(() => ({ +const { fetchMock, invoke, rpc } = vi.hoisted(() => ({ fetchMock: vi.fn(), invoke: vi.fn(), + rpc: vi.fn(), })); vi.mock("../supabaseClient", () => ({ @@ -10,6 +11,7 @@ vi.mock("../supabaseClient", () => ({ supabaseAnonKey: "anon-key", supabaseUrl: "https://supa.example.test", supabase: { + rpc, functions: { invoke, }, @@ -30,6 +32,7 @@ describe("deliveryInvitationApi", () => { beforeEach(() => { fetchMock.mockReset(); invoke.mockReset(); + rpc.mockReset(); vi.stubGlobal("fetch", fetchMock); __resetLocalDeliveryInvitationCache(); const storage = new Map(); @@ -48,15 +51,15 @@ describe("deliveryInvitationApi", () => { }); it("loads a delivery invitation by token", async () => { - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => ({ + rpc.mockResolvedValueOnce({ + data: { ok: true, invitation: { orderId: "order-1", token: "token-1", }, - }), + }, + error: null, }); await expect(fetchDeliveryInvitation("token-1")).resolves.toEqual({ @@ -64,32 +67,20 @@ describe("deliveryInvitationApi", () => { 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(rpc).toHaveBeenCalledWith("get_delivery_invitation_by_token", { + p_token: "token-1", + }); + expect(fetchMock).not.toHaveBeenCalled(); 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", - }), + rpc.mockResolvedValueOnce({ + data: null, + error: new Error("Invitation not found"), }); - await expect(fetchDeliveryInvitation("token-1")).rejects.toThrow("Method not allowed"); + await expect(fetchDeliveryInvitation("token-1")).rejects.toThrow("Invitation not found"); }); it("returns a local showcase invitation for the preview token", async () => { @@ -155,7 +146,7 @@ describe("deliveryInvitationApi", () => { }); it("confirms a delivery choice with the chosen slot", async () => { - invoke.mockResolvedValueOnce({ + rpc.mockResolvedValueOnce({ data: { ok: true, orderId: "order-1", @@ -174,13 +165,12 @@ describe("deliveryInvitationApi", () => { orderId: "order-1", }); - expect(invoke).toHaveBeenCalledWith("confirm-delivery-choice", { - body: { - token: "token-1", - deliveryDate: "2026-04-01", - deliveryTime: "Первая половина дня", - }, + expect(rpc).toHaveBeenCalledWith("confirm_delivery_choice_by_token", { + p_token: "token-1", + p_delivery_date: "2026-04-01", + p_delivery_time: "Первая половина дня", }); + expect(invoke).not.toHaveBeenCalled(); }); it("updates the local client invitation when confirmation falls back to cache", async () => { diff --git a/supabase/schema.sql b/supabase/schema.sql index 936a947..649b4cb 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -540,6 +540,394 @@ begin end; $$; +create or replace function public.get_delivery_invitation_by_token(p_token text) +returns jsonb +language plpgsql +security definer +set search_path = public, extensions +as $$ +declare + v_invitation public.delivery_invitations%rowtype; + v_group public.order_groups%rowtype; + v_order record; + v_token_hash text; + v_state text; + v_order_number text; + v_customer_name text; + v_customer_phone text; + v_order_items jsonb; + v_order_numbers jsonb; + v_now timestamptz := timezone('utc', now()); +begin + if nullif(trim(coalesce(p_token, '')), '') is null then + raise exception 'token is required'; + end if; + + v_token_hash := encode(digest(p_token, 'sha256'), 'hex'); + + select * + into v_invitation + from public.delivery_invitations + where token_hash = v_token_hash; + + if not found then + raise exception 'Invitation not found'; + end if; + + if v_invitation.revoked_at is not null then + raise exception 'Invitation expired'; + end if; + + if v_invitation.expires_at is not null and v_invitation.expires_at <= v_now then + raise exception 'Invitation expired'; + end if; + + if v_invitation.order_group_id is not null then + select * + into v_group + from public.order_groups + where id = v_invitation.order_group_id; + + if not found then + raise exception 'Order group not found'; + end if; + + v_state := case + when v_group.delivery_status = 'agreed' then 'agreed' + when v_group.delivery_status = 'delivered' then 'delivered' + when v_invitation.state in ('awaiting_choice', 'opened', 'reminder_sent') then v_invitation.state + else 'default' + end; + + update public.delivery_invitations + set + opened_at = case + when v_state in ('awaiting_choice', 'opened', 'reminder_sent') and opened_at is null then v_now + else opened_at + end, + access_count = coalesce(access_count, 0) + 1, + last_accessed_at = v_now + where id = v_invitation.id + returning * into v_invitation; + + v_order_number := coalesce( + nullif(v_invitation.order_number, ''), + nullif(v_group.group_key, ''), + to_jsonb(v_group.order_numbers) ->> 0 + ); + v_customer_name := coalesce( + nullif(v_group.customer_name, ''), + nullif(v_group.customer ->> 'name', ''), + nullif(v_invitation.customer_name, '') + ); + v_customer_phone := coalesce( + nullif(v_group.customer_phone, ''), + nullif(v_group.customer ->> 'phone', ''), + nullif(v_invitation.customer_phone, '') + ); + select coalesce( + jsonb_agg(jsonb_build_object('name', order_number, 'quantity', '')), + '[]'::jsonb + ) + into v_order_items + from jsonb_array_elements_text( + case + when jsonb_typeof(to_jsonb(v_group.order_numbers)) = 'array' then to_jsonb(v_group.order_numbers) + else '[]'::jsonb + end + ) as order_number; + + return jsonb_build_object( + 'ok', true, + 'invitation', jsonb_build_object( + 'orderId', coalesce(v_invitation.order_group_id, v_group.id)::text, + 'orderGroupId', coalesce(v_invitation.order_group_id, v_group.id)::text, + 'state', v_state, + 'token', p_token, + 'orderNumber', v_order_number, + 'customerName', v_customer_name, + 'customerPhone', v_customer_phone, + 'orderItems', v_order_items, + 'availableSlots', coalesce(to_jsonb(v_invitation.available_slots), '[]'::jsonb), + 'deliveryDate', v_invitation.delivery_date, + 'deliveryTime', v_invitation.delivery_time, + 'orderStatus', null, + 'deliveryAgreementStatus', null + ) + ); + end if; + + select id, order_number, status, delivery_agreement_status, customer + into v_order + from public.orders + where id = v_invitation.order_id; + + if not found then + raise exception 'Order not found'; + end if; + + v_state := case v_order.status + when 'Ожидает ответа клиента' then 'awaiting_choice' + when 'Ожидает согласования доставки' then 'opened' + when 'Напоминание отправлено' then 'reminder_sent' + when 'Переход отправлен' then 'reminder_sent' + when 'Передан логисту' then 'transferred_to_logistics' + when 'Платное хранение' then 'paid_storage' + when 'Доставлен' then 'delivered' + when 'Доставка согласована' then 'agreed' + else 'default' + end; + + update public.delivery_invitations + set + opened_at = case + when v_state in ('awaiting_choice', 'opened', 'reminder_sent') and opened_at is null then v_now + else opened_at + end, + access_count = coalesce(access_count, 0) + 1, + last_accessed_at = v_now + where id = v_invitation.id + returning * into v_invitation; + + v_order_items := case + when jsonb_typeof(v_order.customer -> 'items') = 'array' then v_order.customer -> 'items' + else '[]'::jsonb + end; + + return jsonb_build_object( + 'ok', true, + 'invitation', jsonb_build_object( + 'orderId', v_invitation.order_id::text, + 'state', v_state, + 'token', p_token, + 'orderNumber', coalesce(nullif(v_order.order_number, ''), nullif(v_invitation.order_number, '')), + 'customerName', coalesce(nullif(v_order.customer ->> 'name', ''), nullif(v_invitation.customer_name, '')), + 'customerPhone', coalesce(nullif(v_order.customer ->> 'phone', ''), nullif(v_invitation.customer_phone, '')), + 'orderItems', v_order_items, + 'availableSlots', coalesce(to_jsonb(v_invitation.available_slots), '[]'::jsonb), + 'deliveryDate', v_invitation.delivery_date, + 'deliveryTime', v_invitation.delivery_time, + 'orderStatus', v_order.status, + 'deliveryAgreementStatus', v_order.delivery_agreement_status + ) + ); +end; +$$; + +create or replace function public.confirm_delivery_choice_by_token( + p_token text, + p_delivery_date date, + p_delivery_time text +) +returns jsonb +language plpgsql +security definer +set search_path = public, extensions +as $$ +declare + v_invitation public.delivery_invitations%rowtype; + v_group public.order_groups%rowtype; + v_order record; + v_token_hash text; + v_slot_label text; + v_now timestamptz := timezone('utc', now()); +begin + if nullif(trim(coalesce(p_token, '')), '') is null then + raise exception 'token is required'; + end if; + + if p_delivery_date is null or nullif(trim(coalesce(p_delivery_time, '')), '') is null then + raise exception 'Selected slot is not available'; + end if; + + v_token_hash := encode(digest(p_token, 'sha256'), 'hex'); + v_slot_label := concat(p_delivery_date::text, ', ', trim(p_delivery_time)); + + select * + into v_invitation + from public.delivery_invitations + where token_hash = v_token_hash + for update; + + if not found then + raise exception 'Invitation not found'; + end if; + + if v_invitation.revoked_at is not null then + raise exception 'Invitation expired'; + end if; + + if v_invitation.expires_at is not null and v_invitation.expires_at <= v_now then + raise exception 'Invitation expired'; + end if; + + if v_invitation.state not in ('awaiting_choice', 'opened', 'reminder_sent') then + raise exception 'Invitation is no longer active'; + end if; + + if cardinality(v_invitation.available_slots) > 0 and not (v_slot_label = any(v_invitation.available_slots)) then + raise exception 'Selected slot is not available'; + end if; + + if v_invitation.order_group_id is not null then + select * + into v_group + from public.order_groups + where id = v_invitation.order_group_id + for update; + + if not found then + raise exception 'Order group not found'; + end if; + + if v_group.delivery_status <> 'pending_confirmation' then + raise exception 'Invitation is no longer active'; + end if; + + update public.delivery_invitations + set + state = 'agreed', + delivery_date = p_delivery_date, + delivery_time = trim(p_delivery_time), + confirmed_at = v_now, + access_count = coalesce(access_count, 0) + 1, + last_accessed_at = v_now + where id = v_invitation.id; + + update public.order_groups + set + delivery_status = 'agreed', + delivery_date = p_delivery_date, + delivery_time = trim(p_delivery_time), + notification_status = 'confirmed', + updated_at = v_now + where id = v_group.id; + + insert into public.integration_events ( + order_id, + event_type, + direction, + status, + payload + ) + values ( + null, + 'delivery_choice_confirmed', + 'inbound', + 'success', + jsonb_build_object( + 'order_group_id', v_group.id, + 'delivery_invitation_id', v_invitation.id, + 'delivery_date', p_delivery_date, + 'delivery_time', trim(p_delivery_time) + ) + ); + + return jsonb_build_object( + 'ok', true, + 'orderGroupId', v_group.id, + 'deliveryStatus', 'agreed' + ); + end if; + + select id, status, delivery_agreement_status + into v_order + from public.orders + where id = v_invitation.order_id + for update; + + if not found then + raise exception 'Order not found'; + end if; + + if v_order.status not in ('Ожидает ответа клиента', 'Ожидает согласования доставки') then + raise exception 'Invitation is no longer active'; + end if; + + update public.delivery_invitations + set + state = 'agreed', + delivery_date = p_delivery_date, + delivery_time = trim(p_delivery_time), + confirmed_at = v_now, + access_count = coalesce(access_count, 0) + 1, + last_accessed_at = v_now + where id = v_invitation.id; + + update public.orders + set + status = 'Доставка согласована', + delivery_agreement_status = 'Подтверждено клиентом' + where id = v_order.id; + + insert into public.delivery_slots ( + order_id, + delivery_date, + delivery_time, + logistician_id, + status + ) + values ( + v_order.id, + p_delivery_date, + trim(p_delivery_time), + null, + 'confirmed_by_client' + ); + + insert into public.order_history ( + order_id, + action, + old_status, + new_status, + metadata + ) + values ( + v_order.id, + 'Подтверждение выбора доставки клиентом', + v_order.status, + 'Доставка согласована', + jsonb_build_object( + 'old_delivery_agreement_status', v_order.delivery_agreement_status, + 'new_delivery_agreement_status', 'Подтверждено клиентом', + 'delivery_date', p_delivery_date, + 'delivery_time', trim(p_delivery_time) + ) + ); + + insert into public.integration_events ( + order_id, + event_type, + direction, + status, + payload + ) + values ( + v_order.id, + 'delivery_choice_confirmed', + 'inbound', + 'success', + jsonb_build_object( + 'delivery_date', p_delivery_date, + 'delivery_time', trim(p_delivery_time) + ) + ); + + return jsonb_build_object( + 'ok', true, + 'orderId', v_order.id, + 'status', 'Доставка согласована', + 'deliveryAgreementStatus', 'Подтверждено клиентом' + ); +end; +$$; + +revoke all on function public.get_delivery_invitation_by_token(text) from public; +grant execute on function public.get_delivery_invitation_by_token(text) to anon, authenticated; + +revoke all on function public.confirm_delivery_choice_by_token(text, date, text) from public; +grant execute on function public.confirm_delivery_choice_by_token(text, date, text) to anon, authenticated; + alter table public.roles enable row level security; alter table public.users enable row level security; alter table public.orders enable row level security;