create extension if not exists pgcrypto; create table if not exists public.roles ( id uuid primary key default gen_random_uuid(), name text not null unique, permissions jsonb not null default '[]'::jsonb, created_at timestamptz not null default timezone('utc', now()) ); create table if not exists public.users ( id uuid primary key references auth.users (id) on delete cascade, email text not null unique, name text not null, role_id uuid not null references public.roles (id), last_login timestamptz, created_at timestamptz not null default timezone('utc', now()) ); create table if not exists public.orders ( id uuid primary key default gen_random_uuid(), order_number text not null unique, customer jsonb not null, status text not null, delivery_agreement_status text not null default 'Не начато', manager_id uuid references public.users (id), logistician_id uuid references public.users (id), assigned_driver_id uuid references public.users (id), ready_for_delivery_at timestamptz, delivery_flow_started_at timestamptz, delivery_flow_source text, created_at timestamptz not null default timezone('utc', now()), updated_at timestamptz not null default timezone('utc', now()) ); create table if not exists public.order_logisticians ( id uuid primary key default gen_random_uuid(), order_id uuid not null references public.orders (id) on delete cascade, logistician_id uuid not null references public.users (id) on delete cascade, assigned_at timestamptz not null default timezone('utc', now()), assigned_by uuid references public.users (id), unique (order_id, logistician_id) ); create table if not exists public.order_history ( id uuid primary key default gen_random_uuid(), order_id uuid not null references public.orders (id) on delete cascade, action text not null, old_status text, new_status text, user_id uuid references public.users (id), metadata jsonb not null default '{}'::jsonb, created_at timestamptz not null default timezone('utc', now()) ); create table if not exists public.delivery_slots ( id uuid primary key default gen_random_uuid(), order_id uuid not null references public.orders (id) on delete cascade, delivery_date date not null, delivery_time text not null, logistician_id uuid references public.users (id), status text not null default 'pending_confirmation', selected_by_client_at timestamptz, created_at timestamptz not null default timezone('utc', now()) ); create table if not exists public.chat_messages ( id uuid primary key default gen_random_uuid(), order_id uuid not null references public.orders (id) on delete cascade, sender_name text, sender_type text not null check (sender_type in ('client', 'bot', 'operator', 'system')), channel text not null check (channel in ('telegram', 'vk', 'messenger_max', 'sms', 'email')), text text not null, external_message_id text, payload jsonb not null default '{}'::jsonb, created_at timestamptz not null default timezone('utc', now()) ); create table if not exists public.error_logs ( id uuid primary key default gen_random_uuid(), order_id uuid references public.orders (id) on delete set null, provider text, level text not null default 'error', message text not null, details jsonb not null default '{}'::jsonb, created_at timestamptz not null default timezone('utc', now()) ); create table if not exists public.delivery_invitations ( id uuid primary key default gen_random_uuid(), order_id uuid not null references public.orders (id) on delete cascade unique, token_hash text not null unique, state text not null default 'awaiting_choice', order_number text, customer_name text, customer_phone text, customer_messenger text, available_slots text[] not null default array['Первая половина дня', 'Вторая половина дня'], expires_at timestamptz, revoked_at timestamptz, access_count integer not null default 0, last_accessed_at timestamptz, delivery_date date, delivery_time text, sent_at timestamptz, opened_at timestamptz, confirmed_at timestamptz, logistics_transferred_at timestamptz, paid_storage_at timestamptz, delivered_at timestamptz, created_at timestamptz not null default timezone('utc', now()), updated_at timestamptz not null default timezone('utc', now()) ); create table if not exists public.integration_events ( id uuid primary key default gen_random_uuid(), order_id uuid references public.orders (id) on delete set null, event_type text not null, direction text not null default 'internal', source text not null default 'supabase-function', status text not null default 'success', payload jsonb not null default '{}'::jsonb, error_message text, created_at timestamptz not null default timezone('utc', now()) ); create table if not exists public.rate_limits ( id uuid primary key default gen_random_uuid(), scope text not null, rate_key text not null, window_start timestamptz not null, count integer not null default 1, blocked_until timestamptz, created_at timestamptz not null default timezone('utc', now()), updated_at timestamptz not null default timezone('utc', now()), unique (scope, rate_key, window_start) ); alter table public.orders add column if not exists delivery_agreement_status text not null default 'Не начато'; alter table public.orders add column if not exists assigned_driver_id uuid references public.users (id); alter table public.orders add column if not exists ready_for_delivery_at timestamptz; alter table public.orders add column if not exists delivery_flow_started_at timestamptz; alter table public.orders add column if not exists delivery_flow_source text; alter table public.chat_messages drop constraint if exists chat_messages_channel_check; alter table public.chat_messages add constraint chat_messages_channel_check check (channel in ('telegram', 'vk', 'messenger_max', 'sms', 'email')); alter table public.delivery_invitations add column if not exists state text not null default 'awaiting_choice'; alter table public.delivery_invitations add column if not exists expires_at timestamptz; alter table public.delivery_invitations add column if not exists revoked_at timestamptz; alter table public.delivery_invitations add column if not exists access_count integer not null default 0; alter table public.delivery_invitations add column if not exists last_accessed_at timestamptz; alter table public.delivery_invitations add column if not exists delivery_date date; alter table public.delivery_invitations add column if not exists delivery_time text; alter table public.delivery_invitations add column if not exists sent_at timestamptz; alter table public.delivery_invitations add column if not exists opened_at timestamptz; alter table public.delivery_invitations add column if not exists confirmed_at timestamptz; alter table public.delivery_invitations add column if not exists logistics_transferred_at timestamptz; alter table public.delivery_invitations add column if not exists paid_storage_at timestamptz; alter table public.delivery_invitations add column if not exists delivered_at timestamptz; alter table public.delivery_invitations add column if not exists updated_at timestamptz not null default timezone('utc', now()); alter table public.orders add column if not exists source_order_number text; alter table public.orders add column if not exists source_order_date date; alter table public.orders add column if not exists source_customer_name text; alter table public.orders add column if not exists source_customer_phone text; alter table public.orders add column if not exists source_customer_email text; alter table public.orders add column if not exists source_customer_city text; alter table public.orders add column if not exists source_total_sum numeric; alter table public.orders add column if not exists source_paid_at timestamptz; alter table public.orders add column if not exists source_gateway text; alter table public.orders add column if not exists source_associated_bills_text text; alter table public.orders add column if not exists source_production_at timestamptz; alter table public.orders add column if not exists source_saw_at timestamptz; alter table public.orders add column if not exists source_glue_at timestamptz; alter table public.orders add column if not exists source_h_glue_at timestamptz; alter table public.orders add column if not exists source_curve_at timestamptz; alter table public.orders add column if not exists source_accept_at timestamptz; alter table public.orders add column if not exists source_ship_at timestamptz; alter table public.orders add column if not exists source_payload jsonb; alter table public.orders add column if not exists delivery_set_key text; alter table public.orders add column if not exists delivery_set_name text; alter table public.orders add column if not exists delivery_set_status text; alter table public.orders add column if not exists delivery_set_ready_at timestamptz; alter table public.orders add column if not exists delivery_ready_reason text; alter table public.orders add column if not exists source_sms_legacy_at timestamptz; comment on column public.orders.source_sms_legacy_at is 'Informational only: legacy 1C SMS timestamp. Must NOT be used to start new delivery automation scenarios.'; alter table public.integration_events add column if not exists direction text not null default 'internal'; alter table public.integration_events add column if not exists source text not null default 'supabase-function'; alter table public.integration_events add column if not exists status text not null default 'success'; alter table public.integration_events add column if not exists payload jsonb not null default '{}'::jsonb; alter table public.integration_events add column if not exists error_message text; alter table public.rate_limits add column if not exists scope text not null; alter table public.rate_limits add column if not exists rate_key text not null; alter table public.rate_limits add column if not exists window_start timestamptz not null; alter table public.rate_limits add column if not exists count integer not null default 1; alter table public.rate_limits add column if not exists blocked_until timestamptz; alter table public.rate_limits add column if not exists created_at timestamptz not null default timezone('utc', now()); alter table public.rate_limits add column if not exists updated_at timestamptz not null default timezone('utc', now()); create index if not exists idx_orders_delivery_set_key on public.orders (delivery_set_key); create index if not exists idx_orders_delivery_set_status on public.orders (delivery_set_status); create index if not exists idx_orders_source_accept_at on public.orders (source_accept_at); create index if not exists idx_orders_source_ship_at on public.orders (source_ship_at); insert into public.roles (name, permissions) values ( 'manager', '["orders.create","orders.update.own","orders.read.own","comments.manage"]'::jsonb ), ( 'production_lead', '["orders.read.all","production.queue.manage","orders.status.production"]'::jsonb ), ( 'logistician', '["orders.read.assigned","delivery.manage","chatbots.manage"]'::jsonb ), ( 'driver', '["orders.read.assigned_driver","orders.status.driver"]'::jsonb ), ( 'admin', '["*"]'::jsonb ) on conflict (name) do nothing; create or replace function public.set_updated_at() returns trigger language plpgsql as $$ begin new.updated_at = timezone('utc', now()); return new; end; $$; drop trigger if exists orders_set_updated_at on public.orders; create trigger orders_set_updated_at before update on public.orders for each row execute function public.set_updated_at(); drop trigger if exists delivery_invitations_set_updated_at on public.delivery_invitations; create trigger delivery_invitations_set_updated_at before update on public.delivery_invitations for each row execute function public.set_updated_at(); create or replace function public.current_role_name() returns text language sql stable security definer set search_path = public as $$ select r.name from public.users u join public.roles r on r.id = u.role_id where u.id = auth.uid() $$; create or replace function public.handle_new_user() returns trigger language plpgsql security definer set search_path = public as $$ declare default_role_id uuid; begin select id into default_role_id from public.roles where name = coalesce(new.raw_user_meta_data ->> 'role', 'manager') limit 1; if default_role_id is null then select id into default_role_id from public.roles where name = 'manager' limit 1; end if; insert into public.users (id, email, name, role_id, last_login) values ( new.id, new.email, coalesce(new.raw_user_meta_data ->> 'name', split_part(new.email, '@', 1)), default_role_id, timezone('utc', now()) ) on conflict (id) do update set email = excluded.email, last_login = timezone('utc', now()); return new; end; $$; drop trigger if exists on_auth_user_created on auth.users; create trigger on_auth_user_created after insert on auth.users for each row execute function public.handle_new_user(); create or replace function public.log_order_status_change() returns trigger language plpgsql security definer as $$ begin if tg_op = 'INSERT' then insert into public.order_history (order_id, action, old_status, new_status, user_id) values (new.id, 'Создан заказ', null, new.status, auth.uid()); return new; end if; if old.status is distinct from new.status then insert into public.order_history (order_id, action, old_status, new_status, user_id) values (new.id, 'Изменение статуса', old.status, new.status, auth.uid()); end if; if old.delivery_agreement_status is distinct from new.delivery_agreement_status then insert into public.order_history (order_id, action, old_status, new_status, user_id, metadata) values ( new.id, 'Изменение согласования доставки', old.delivery_agreement_status, new.delivery_agreement_status, auth.uid(), jsonb_build_object('scope', 'delivery_agreement') ); end if; return new; end; $$; drop trigger if exists orders_history_insert on public.orders; create trigger orders_history_insert after insert or update on public.orders for each row execute function public.log_order_status_change(); create index if not exists idx_users_role_id on public.users (role_id); create index if not exists idx_orders_status on public.orders (status); create index if not exists idx_orders_manager_id on public.orders (manager_id); create index if not exists idx_orders_logistician_id on public.orders (logistician_id); create index if not exists idx_orders_assigned_driver_id on public.orders (assigned_driver_id); create index if not exists idx_orders_ready_for_delivery_at on public.orders (ready_for_delivery_at); create index if not exists idx_orders_delivery_flow_started_at on public.orders (delivery_flow_started_at); create index if not exists idx_orders_created_at on public.orders (created_at desc); create index if not exists idx_order_logisticians_order_id on public.order_logisticians (order_id); create index if not exists idx_order_logisticians_logistician_id on public.order_logisticians (logistician_id); create index if not exists idx_order_history_order_id on public.order_history (order_id, created_at desc); create index if not exists idx_delivery_slots_order_id on public.delivery_slots (order_id); create index if not exists idx_delivery_slots_logistician_id on public.delivery_slots (logistician_id); create index if not exists idx_delivery_slots_selected_by_client_at on public.delivery_slots (selected_by_client_at); create index if not exists idx_chat_messages_order_id on public.chat_messages (order_id, created_at desc); create index if not exists idx_chat_messages_external_message_id on public.chat_messages (external_message_id); create unique index if not exists idx_chat_messages_channel_external_unique on public.chat_messages (channel, external_message_id) where external_message_id is not null; create index if not exists idx_orders_search on public.orders using gin ( to_tsvector( 'simple', coalesce(order_number, '') || ' ' || coalesce(customer ->> 'name', '') || ' ' || coalesce(customer ->> 'phone', '') ) ); create index if not exists idx_chat_messages_search on public.chat_messages using gin ( to_tsvector('russian', coalesce(text, '')) ); create index if not exists idx_delivery_invitations_order_id on public.delivery_invitations (order_id); create index if not exists idx_delivery_invitations_token_hash on public.delivery_invitations (token_hash); create index if not exists idx_delivery_invitations_state on public.delivery_invitations (state); create index if not exists idx_delivery_invitations_expires_at on public.delivery_invitations (expires_at); create index if not exists idx_integration_events_order_id on public.integration_events (order_id, created_at desc); create index if not exists idx_integration_events_event_type on public.integration_events (event_type); create index if not exists idx_rate_limits_scope_key_window on public.rate_limits (scope, rate_key, window_start desc); create index if not exists idx_rate_limits_blocked_until on public.rate_limits (blocked_until); create or replace function public.check_rate_limit( p_scope text, p_key text, p_max_count integer, p_window_seconds integer, p_block_seconds integer default 0 ) returns table ( allowed boolean, current_count integer, limit_count integer, blocked_until timestamptz, window_start timestamptz ) language plpgsql security definer set search_path = public as $$ declare v_now timestamptz := timezone('utc', now()); v_window_start timestamptz; v_count integer; v_blocked_until timestamptz; begin if p_max_count <= 0 then raise exception 'max_count must be positive'; end if; if p_window_seconds <= 0 then raise exception 'window_seconds must be positive'; end if; v_window_start := to_timestamp(floor(extract(epoch from v_now) / p_window_seconds) * p_window_seconds); select rl.blocked_until into v_blocked_until from public.rate_limits rl where rl.scope = p_scope and rl.rate_key = p_key and rl.blocked_until is not null and rl.blocked_until > v_now order by rl.blocked_until desc limit 1; if v_blocked_until is not null then return query select false, 0, p_max_count, v_blocked_until, v_window_start; return; end if; insert into public.rate_limits (scope, rate_key, window_start, count, blocked_until) values (p_scope, p_key, v_window_start, 1, null) on conflict (scope, rate_key, window_start) do update set count = public.rate_limits.count + 1, blocked_until = case when public.rate_limits.count + 1 > p_max_count and p_block_seconds > 0 then greatest( coalesce(public.rate_limits.blocked_until, v_now), v_now + make_interval(secs => p_block_seconds) ) else public.rate_limits.blocked_until end, updated_at = v_now returning count, blocked_until into v_count, v_blocked_until; return query select v_count <= p_max_count and (v_blocked_until is null or v_blocked_until <= v_now), v_count, p_max_count, v_blocked_until, v_window_start; end; $$; alter table public.roles enable row level security; alter table public.users enable row level security; alter table public.orders enable row level security; alter table public.order_logisticians enable row level security; alter table public.order_history enable row level security; alter table public.delivery_slots enable row level security; alter table public.chat_messages enable row level security; alter table public.error_logs enable row level security; alter table public.delivery_invitations enable row level security; alter table public.integration_events enable row level security; drop policy if exists "roles select authenticated" on public.roles; create policy "roles select authenticated" on public.roles for select using (public.current_role_name() is not null); drop policy if exists "roles admin mutate" on public.roles; create policy "roles admin mutate" on public.roles for insert with check (public.current_role_name() = 'admin'); drop policy if exists "roles admin update" on public.roles; create policy "roles admin update" on public.roles for update using (public.current_role_name() = 'admin') with check (public.current_role_name() = 'admin'); drop policy if exists "roles admin delete" on public.roles; create policy "roles admin delete" on public.roles for delete using (public.current_role_name() = 'admin'); drop policy if exists "users self or admin" on public.users; create policy "users self or admin" on public.users for select using (public.current_role_name() = 'admin' or id = auth.uid()); drop policy if exists "users admin update" on public.users; create policy "users admin update" on public.users for all using (public.current_role_name() = 'admin') with check (public.current_role_name() = 'admin'); drop policy if exists "orders select by role" on public.orders; create policy "orders select by role" on public.orders for select using ( public.current_role_name() = 'admin' or public.current_role_name() = 'production_lead' or (public.current_role_name() = 'manager' and manager_id = auth.uid()) or (public.current_role_name() = 'driver' and assigned_driver_id = auth.uid()) or ( public.current_role_name() = 'logistician' and ( logistician_id = auth.uid() or exists ( select 1 from public.order_logisticians ol where ol.order_id = orders.id and ol.logistician_id = auth.uid() ) ) ) ); drop policy if exists "orders insert managers admin" on public.orders; create policy "orders insert managers admin" on public.orders for insert with check (public.current_role_name() in ('manager', 'admin')); drop policy if exists "orders update by workflow role" on public.orders; create policy "orders update by workflow role" on public.orders for update using ( public.current_role_name() = 'admin' or (public.current_role_name() = 'manager' and manager_id = auth.uid()) or (public.current_role_name() = 'driver' and assigned_driver_id = auth.uid()) or public.current_role_name() = 'production_lead' or ( public.current_role_name() = 'logistician' and ( logistician_id = auth.uid() or exists ( select 1 from public.order_logisticians ol where ol.order_id = orders.id and ol.logistician_id = auth.uid() ) ) ) ) with check ( public.current_role_name() = 'admin' or (public.current_role_name() = 'manager' and manager_id = auth.uid()) or (public.current_role_name() = 'driver' and assigned_driver_id = auth.uid()) or public.current_role_name() = 'production_lead' or ( public.current_role_name() = 'logistician' and ( logistician_id = auth.uid() or exists ( select 1 from public.order_logisticians ol where ol.order_id = orders.id and ol.logistician_id = auth.uid() ) ) ) ); drop policy if exists "history select by order role" on public.order_history; create policy "history select by order role" on public.order_history for select using ( exists ( select 1 from public.orders o where o.id = order_history.order_id and ( public.current_role_name() = 'admin' or public.current_role_name() = 'production_lead' or (public.current_role_name() = 'manager' and o.manager_id = auth.uid()) or (public.current_role_name() = 'driver' and o.assigned_driver_id = auth.uid()) or ( public.current_role_name() = 'logistician' and ( o.logistician_id = auth.uid() or exists ( select 1 from public.order_logisticians ol where ol.order_id = o.id and ol.logistician_id = auth.uid() ) ) ) ) ) ); drop policy if exists "history insert workflow" on public.order_history; create policy "history insert workflow" on public.order_history for insert with check (public.current_role_name() in ('manager', 'production_lead', 'logistician', 'driver', 'admin')); drop policy if exists "slots by order role" on public.delivery_slots; create policy "slots by order role" on public.delivery_slots for all using ( exists ( select 1 from public.orders o where o.id = delivery_slots.order_id and ( public.current_role_name() = 'admin' or public.current_role_name() = 'production_lead' or (public.current_role_name() = 'manager' and o.manager_id = auth.uid()) or (public.current_role_name() = 'driver' and o.assigned_driver_id = auth.uid()) or ( public.current_role_name() = 'logistician' and ( o.logistician_id = auth.uid() or exists ( select 1 from public.order_logisticians ol where ol.order_id = o.id and ol.logistician_id = auth.uid() ) ) ) ) ) ) with check (public.current_role_name() in ('logistician', 'admin')); drop policy if exists "chat by order role" on public.chat_messages; create policy "chat by order role" on public.chat_messages for select using ( exists ( select 1 from public.orders o where o.id = chat_messages.order_id and ( public.current_role_name() = 'admin' or public.current_role_name() = 'production_lead' or (public.current_role_name() = 'manager' and o.manager_id = auth.uid()) or (public.current_role_name() = 'driver' and o.assigned_driver_id = auth.uid()) or ( public.current_role_name() = 'logistician' and ( o.logistician_id = auth.uid() or exists ( select 1 from public.order_logisticians ol where ol.order_id = o.id and ol.logistician_id = auth.uid() ) ) ) ) ) ); drop policy if exists "chat insert workflow" on public.chat_messages; create policy "chat insert workflow" on public.chat_messages for insert with check (public.current_role_name() in ('manager', 'logistician', 'admin')); drop policy if exists "order logisticians by role" on public.order_logisticians; create policy "order logisticians by role" on public.order_logisticians for all using (public.current_role_name() in ('logistician', 'admin')) with check (public.current_role_name() in ('logistician', 'admin')); drop policy if exists "error logs admin only" on public.error_logs; create policy "error logs admin only" on public.error_logs for all using (public.current_role_name() = 'admin') with check (public.current_role_name() = 'admin'); drop policy if exists "delivery invitations admin only" on public.delivery_invitations; create policy "delivery invitations admin only" on public.delivery_invitations for all using (public.current_role_name() = 'admin') with check (public.current_role_name() = 'admin'); alter table public.rate_limits enable row level security; drop policy if exists "rate limits admin only" on public.rate_limits; create policy "rate limits admin only" on public.rate_limits for all using (public.current_role_name() = 'admin') with check (public.current_role_name() = 'admin'); drop policy if exists "integration events admin only" on public.integration_events; create policy "integration events admin only" on public.integration_events for all using (public.current_role_name() = 'admin') with check (public.current_role_name() = 'admin');