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), 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', 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()) ); 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.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')); 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(); 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_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_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, '')) ); 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; drop policy if exists "roles admin only" on public.roles; create policy "roles admin only" on public.roles for all using (public.current_role_name() = 'admin') with check (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 (id = auth.uid() or public.current_role_name() = 'admin'); 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');