fix(delivery): switch public flow to rpc
This commit is contained in:
parent
7e399f2517
commit
eeb2620547
|
|
@ -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;
|
||||||
|
|
@ -311,6 +311,10 @@ export const ClientDeliveryPage = () => {
|
||||||
<Panel className="space-y-2 p-5 sm:p-6">
|
<Panel className="space-y-2 p-5 sm:p-6">
|
||||||
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Ваш выбор</p>
|
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Ваш выбор</p>
|
||||||
<h2 className="text-xl font-semibold leading-tight">Сохранено: {savedChoiceLabel}</h2>
|
<h2 className="text-xl font-semibold leading-tight">Сохранено: {savedChoiceLabel}</h2>
|
||||||
|
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
|
Заказ {invitation?.orderNumber || "—"}
|
||||||
|
{invitation?.customerName ? ` · ${invitation.customerName}` : ""}
|
||||||
|
</p>
|
||||||
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
Статус: доставка уже согласована. При повторном открытии этой ссылки будет показан тот же выбор.
|
Статус: доставка уже согласована. При повторном открытии этой ссылки будет показан тот же выбор.
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import {
|
import {
|
||||||
supabase,
|
supabase,
|
||||||
hasSupabaseConfig,
|
hasSupabaseConfig,
|
||||||
supabaseAnonKey,
|
|
||||||
supabaseUrl,
|
|
||||||
} from "../supabaseClient";
|
} from "../supabaseClient";
|
||||||
|
|
||||||
const SHOWCASE_TOKEN = "showcase";
|
const SHOWCASE_TOKEN = "showcase";
|
||||||
|
|
@ -163,29 +161,19 @@ const invokeDeliveryFunction = async (functionName, body) => {
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchDeliveryFunction = async (functionName, params) => {
|
const callDeliveryRpc = async (functionName, params) => {
|
||||||
if (!hasSupabaseConfig || !supabaseUrl || !supabaseAnonKey) {
|
if (!hasSupabaseConfig || !supabase?.rpc) {
|
||||||
throw new Error("Supabase is not configured");
|
throw new Error("Supabase is not configured");
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(`${supabaseUrl.replace(/\/$/, "")}/functions/v1/${functionName}`);
|
const { data, error } = await supabase.rpc(functionName, params);
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
|
||||||
if (value !== undefined && value !== null) {
|
|
||||||
url.searchParams.set(key, String(value));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
if (error) {
|
||||||
method: "GET",
|
throw error;
|
||||||
headers: {
|
}
|
||||||
apikey: supabaseAnonKey,
|
|
||||||
Authorization: `Bearer ${supabaseAnonKey}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const data = await response.json().catch(() => null);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (data && data.ok === false) {
|
||||||
throw new Error(data?.error || `Request failed with status ${response.status}`);
|
throw new Error(data.error || "Request failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
|
|
@ -216,7 +204,9 @@ export const fetchDeliveryInvitation = async (token) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetchDeliveryFunction("get-delivery-invitation", { token });
|
const response = await callDeliveryRpc("get_delivery_invitation_by_token", {
|
||||||
|
p_token: token,
|
||||||
|
});
|
||||||
if (response?.invitation) {
|
if (response?.invitation) {
|
||||||
return cacheInvitation(response.invitation);
|
return cacheInvitation(response.invitation);
|
||||||
}
|
}
|
||||||
|
|
@ -248,10 +238,10 @@ export const confirmDeliveryChoice = async ({ token, deliveryDate, deliveryTime
|
||||||
return { ok: true, invitation };
|
return { ok: true, invitation };
|
||||||
}
|
}
|
||||||
|
|
||||||
return invokeDeliveryFunction("confirm-delivery-choice", {
|
return callDeliveryRpc("confirm_delivery_choice_by_token", {
|
||||||
token,
|
p_token: token,
|
||||||
deliveryDate,
|
p_delivery_date: deliveryDate,
|
||||||
deliveryTime,
|
p_delivery_time: deliveryTime,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const { fetchMock, invoke } = vi.hoisted(() => ({
|
const { fetchMock, invoke, rpc } = vi.hoisted(() => ({
|
||||||
fetchMock: vi.fn(),
|
fetchMock: vi.fn(),
|
||||||
invoke: vi.fn(),
|
invoke: vi.fn(),
|
||||||
|
rpc: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../supabaseClient", () => ({
|
vi.mock("../supabaseClient", () => ({
|
||||||
|
|
@ -10,6 +11,7 @@ vi.mock("../supabaseClient", () => ({
|
||||||
supabaseAnonKey: "anon-key",
|
supabaseAnonKey: "anon-key",
|
||||||
supabaseUrl: "https://supa.example.test",
|
supabaseUrl: "https://supa.example.test",
|
||||||
supabase: {
|
supabase: {
|
||||||
|
rpc,
|
||||||
functions: {
|
functions: {
|
||||||
invoke,
|
invoke,
|
||||||
},
|
},
|
||||||
|
|
@ -30,6 +32,7 @@ describe("deliveryInvitationApi", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fetchMock.mockReset();
|
fetchMock.mockReset();
|
||||||
invoke.mockReset();
|
invoke.mockReset();
|
||||||
|
rpc.mockReset();
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
__resetLocalDeliveryInvitationCache();
|
__resetLocalDeliveryInvitationCache();
|
||||||
const storage = new Map();
|
const storage = new Map();
|
||||||
|
|
@ -48,15 +51,15 @@ describe("deliveryInvitationApi", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("loads a delivery invitation by token", async () => {
|
it("loads a delivery invitation by token", async () => {
|
||||||
fetchMock.mockResolvedValueOnce({
|
rpc.mockResolvedValueOnce({
|
||||||
ok: true,
|
data: {
|
||||||
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({
|
||||||
|
|
@ -64,32 +67,20 @@ describe("deliveryInvitationApi", () => {
|
||||||
token: "token-1",
|
token: "token-1",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
expect(rpc).toHaveBeenCalledWith("get_delivery_invitation_by_token", {
|
||||||
expect.objectContaining({
|
p_token: "token-1",
|
||||||
href: "https://supa.example.test/functions/v1/get-delivery-invitation?token=token-1",
|
});
|
||||||
}),
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
apikey: "anon-key",
|
|
||||||
Authorization: "Bearer anon-key",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(invoke).not.toHaveBeenCalled();
|
expect(invoke).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws a readable error when loading invitation fails", async () => {
|
it("throws a readable error when loading invitation fails", async () => {
|
||||||
fetchMock.mockResolvedValueOnce({
|
rpc.mockResolvedValueOnce({
|
||||||
ok: false,
|
data: null,
|
||||||
status: 405,
|
error: new Error("Invitation not found"),
|
||||||
json: async () => ({
|
|
||||||
ok: false,
|
|
||||||
error: "Method not allowed",
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 () => {
|
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 () => {
|
it("confirms a delivery choice with the chosen slot", async () => {
|
||||||
invoke.mockResolvedValueOnce({
|
rpc.mockResolvedValueOnce({
|
||||||
data: {
|
data: {
|
||||||
ok: true,
|
ok: true,
|
||||||
orderId: "order-1",
|
orderId: "order-1",
|
||||||
|
|
@ -174,13 +165,12 @@ describe("deliveryInvitationApi", () => {
|
||||||
orderId: "order-1",
|
orderId: "order-1",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(invoke).toHaveBeenCalledWith("confirm-delivery-choice", {
|
expect(rpc).toHaveBeenCalledWith("confirm_delivery_choice_by_token", {
|
||||||
body: {
|
p_token: "token-1",
|
||||||
token: "token-1",
|
p_delivery_date: "2026-04-01",
|
||||||
deliveryDate: "2026-04-01",
|
p_delivery_time: "Первая половина дня",
|
||||||
deliveryTime: "Первая половина дня",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
expect(invoke).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates the local client invitation when confirmation falls back to cache", async () => {
|
it("updates the local client invitation when confirmation falls back to cache", async () => {
|
||||||
|
|
|
||||||
|
|
@ -540,6 +540,394 @@ begin
|
||||||
end;
|
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.roles enable row level security;
|
||||||
alter table public.users enable row level security;
|
alter table public.users enable row level security;
|
||||||
alter table public.orders enable row level security;
|
alter table public.orders enable row level security;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue