-- Server-side, tamper-proof audit trail for order changes. -- The owner needs to see when an employee with orders.* permissions -- created, edited or deleted an order, on any device, at any time — -- not just in a local per-browser history that an employee could -- clear themselves. This writes from inside sun_save_app_state, -- which already detects created/deleted/edited orders server-side -- for permission checks, so the log can't be spoofed or skipped by -- the client and needs no extra client-side call. create table if not exists public.sun_order_audit_log ( id bigint generated always as identity primary key, workspace_id uuid not null references public.sun_workspaces(id) on delete cascade, order_id text not null, action text not null check (action in ('create','update','delete')), actor_id uuid references auth.users(id) on delete set null, actor_name text not null default '', actor_role text not null default '', summary text not null default '', details jsonb not null default '{}'::jsonb, created_at timestamptz not null default now() ); create index if not exists sun_order_audit_log_workspace_idx on public.sun_order_audit_log(workspace_id, created_at desc); -- Only the security-definer functions below (running as their owner, -- which bypasses RLS) ever write here. No insert/update/delete policy -- is granted to end users, so an employee cannot edit or clear their -- own trail the way the old local-only history could be cleared. alter table public.sun_order_audit_log enable row level security; revoke all on public.sun_order_audit_log from public, anon, authenticated; create or replace function public.sun_list_order_audit(p_workspace uuid, p_limit integer default 200) returns table( id bigint, order_id text, action text, actor_name text, actor_role text, summary text, details jsonb, created_at timestamptz ) language plpgsql stable security definer set search_path = public as $$ begin if coalesce(public.sun_member_role(p_workspace),'') <> 'admin' then raise exception 'Administrator required'; end if; return query select l.id, l.order_id, l.action, l.actor_name, l.actor_role, l.summary, l.details, l.created_at from public.sun_order_audit_log l where l.workspace_id = p_workspace order by l.created_at desc limit least(greatest(coalesce(p_limit,200),1),1000); end; $$; revoke all on function public.sun_list_order_audit(uuid,integer) from public, anon; grant execute on function public.sun_list_order_audit(uuid,integer) to authenticated; create or replace function public.sun_save_app_state(p_workspace uuid, p_payload jsonb, p_client_id text) returns table(workspace_id uuid, payload jsonb, revision bigint, updated_at timestamptz, client_id text) language plpgsql security definer set search_path='public' as $$ declare v_old jsonb := '{"format":"sun-cloud-v2","version":2,"storage":{}}'::jsonb; v_old_storage jsonb; v_new_storage jsonb; v_final_storage jsonb; v_final_payload jsonb; v_key text; v_perm text; v_feature text; v_old_orders jsonb; v_new_orders jsonb; v_create boolean; v_delete boolean; v_edit boolean; v_saved public.sun_app_state%rowtype; v_actor_name text; v_actor_role text; v_row jsonb; begin if public.sun_member_role(p_workspace) is null then raise exception 'Access denied'; end if; if public.sun_subscription_access_mode(p_workspace)<>'full' then raise exception 'Подписка истекла: база доступна только для просмотра'; end if; if p_payload is null or jsonb_typeof(p_payload) <> 'object' or jsonb_typeof(coalesce(p_payload->'storage','{}'::jsonb)) <> 'object' then raise exception 'Invalid payload'; end if; select a.payload into v_old from public.sun_app_state a where a.workspace_id=p_workspace; if v_old is null then v_old := '{"format":"sun-cloud-v2","version":2,"storage":{}}'::jsonb; end if; v_old_storage := coalesce(v_old->'storage','{}'::jsonb); v_new_storage := coalesce(p_payload->'storage','{}'::jsonb); v_final_storage := v_old_storage; for v_key in select key from (select jsonb_object_keys(v_old_storage) key union select jsonb_object_keys(v_new_storage) key) q loop if not public.sun_can_read_storage_key(p_workspace,v_key) and not (v_new_storage ? v_key) then continue; end if; if coalesce(v_old_storage->v_key,'null'::jsonb) = coalesce(v_new_storage->v_key,'null'::jsonb) then continue; end if; if v_key='sunOrders' then if not public.sun_workspace_has_feature(p_workspace,'orders') then raise exception 'Заказы недоступны на текущем тарифе'; end if; v_old_orders := coalesce(v_old_storage #> array['sunOrders','v'],'[]'::jsonb); v_new_orders := coalesce(v_new_storage #> array['sunOrders','v'],'[]'::jsonb); if jsonb_typeof(v_old_orders)<>'array' or jsonb_typeof(v_new_orders)<>'array' then raise exception 'Invalid orders payload'; end if; select exists(select 1 from jsonb_array_elements(v_new_orders) n where not exists(select 1 from jsonb_array_elements(v_old_orders) o where o->>'id'=n->>'id')) into v_create; select exists(select 1 from jsonb_array_elements(v_old_orders) o where not exists(select 1 from jsonb_array_elements(v_new_orders) n where n->>'id'=o->>'id')) into v_delete; select exists(select 1 from jsonb_array_elements(v_new_orders) n join lateral (select o from jsonb_array_elements(v_old_orders) o where o->>'id'=n->>'id' limit 1) x on true where x.o<>n) into v_edit; if v_create and not public.sun_has_permission(p_workspace,'orders.create') then raise exception 'Нет права создавать заказы'; end if; if v_edit and not public.sun_has_permission(p_workspace,'orders.edit') then raise exception 'Нет права изменять заказы'; end if; if v_delete and not public.sun_has_permission(p_workspace,'orders.delete') then raise exception 'Нет права удалять заказы'; end if; if v_create or v_edit or v_delete then select m.display_name, m.role into v_actor_name, v_actor_role from public.sun_workspace_members m where m.workspace_id=p_workspace and m.user_id=auth.uid(); if coalesce(trim(v_actor_name),'')='' then select u.email into v_actor_name from auth.users u where u.id=auth.uid(); end if; v_actor_name := coalesce(nullif(trim(v_actor_name),''),'Сотрудник'); v_actor_role := coalesce(v_actor_role,''); for v_row in select n from jsonb_array_elements(v_new_orders) n where not exists(select 1 from jsonb_array_elements(v_old_orders) o where o->>'id'=(n)->>'id') loop insert into public.sun_order_audit_log(workspace_id,order_id,action,actor_id,actor_name,actor_role,summary,details) values (p_workspace, coalesce(v_row->>'id',''), 'create', auth.uid(), v_actor_name, v_actor_role, format('№%s · %s', coalesce(v_row->>'id','?'), coalesce(nullif(v_row->>'event',''),'Заказ')), jsonb_build_object('event',v_row->'event','date',v_row->'date','status',v_row->'status','total',v_row->'total')); end loop; for v_row in select o from jsonb_array_elements(v_old_orders) o where not exists(select 1 from jsonb_array_elements(v_new_orders) n where n->>'id'=(o)->>'id') loop insert into public.sun_order_audit_log(workspace_id,order_id,action,actor_id,actor_name,actor_role,summary,details) values (p_workspace, coalesce(v_row->>'id',''), 'delete', auth.uid(), v_actor_name, v_actor_role, format('№%s · %s', coalesce(v_row->>'id','?'), coalesce(nullif(v_row->>'event',''),'Заказ')), jsonb_build_object('event',v_row->'event','date',v_row->'date','status',v_row->'status','total',v_row->'total')); end loop; for v_row in select n from jsonb_array_elements(v_new_orders) n join lateral (select o from jsonb_array_elements(v_old_orders) o where o->>'id'=(n)->>'id' limit 1) x on true where x.o<>n loop insert into public.sun_order_audit_log(workspace_id,order_id,action,actor_id,actor_name,actor_role,summary,details) values (p_workspace, coalesce(v_row->>'id',''), 'update', auth.uid(), v_actor_name, v_actor_role, format('№%s · %s', coalesce(v_row->>'id','?'), coalesce(nullif(v_row->>'event',''),'Заказ')), jsonb_build_object('event',v_row->'event','date',v_row->'date','status',v_row->'status','total',v_row->'total','prepayment',v_row->'prepayment')); end loop; end if; else v_feature := public.sun_feature_for_storage_key(v_key); if v_feature is not null and not public.sun_workspace_has_feature(p_workspace,v_feature) then raise exception 'Функция недоступна на текущем тарифе: %',v_feature; end if; v_perm := public.sun_required_write_permission(v_key); if v_perm='_member' then null; elsif not public.sun_has_permission(p_workspace,v_perm) then raise exception 'Нет права изменять раздел: %',v_key; end if; end if; if v_new_storage ? v_key then v_final_storage := v_final_storage || jsonb_build_object(v_key,v_new_storage->v_key); else v_final_storage := v_final_storage - v_key; end if; end loop; v_final_payload := jsonb_build_object('format',coalesce(p_payload->'format',v_old->'format','"sun-cloud-v2"'::jsonb),'version',coalesce(p_payload->'version',v_old->'version','2'::jsonb),'storage',v_final_storage); insert into public.sun_app_state as app_state(workspace_id,payload,client_id) values (p_workspace,v_final_payload,p_client_id) on conflict on constraint sun_app_state_pkey do update set payload=excluded.payload,client_id=excluded.client_id returning app_state.* into v_saved; insert into public.sun_sync_events(workspace_id,revision,client_id) values (p_workspace,v_saved.revision,p_client_id); return query select * from public.sun_fetch_app_state(p_workspace); end; $$; revoke all on function public.sun_save_app_state(uuid,jsonb,text) from public, anon; grant execute on function public.sun_save_app_state(uuid,jsonb,text) to authenticated; notify pgrst, 'reload schema';