From ab6d41ba0e4e93a75cb4cae133e09ea4016503cf Mon Sep 17 00:00:00 2001 From: pavlov346346-source Date: Tue, 22 Sep 2026 18:56:39 +0300 Subject: [PATCH] feat: server-side order audit log for company owners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owners/admins can now see, on the Аккаунт settings tab, a tamper-proof journal of who created, edited or deleted each order and when — including employees who have orders.* permissions. It is written from inside sun_save_app_state itself (which already diffs orders server-side for permission checks), so it can't be spoofed or wiped by the client, unlike the old per-browser 'История изменений' list which only covered the current device and had a 'Clear history' button anyone could press. - New table public.sun_order_audit_log (workspace, order id, action, actor, summary, details), locked down to security-definer writes only — no client insert/update/delete policy exists. - New RPC sun_list_order_audit(workspace, limit), admin-only. - New settings card 'Журнал заказов' reading it, admin-only, classified into the existing Аккаунт settings tab. - Verified end-to-end against a local PGlite instance: create/edit/ delete each produce one correctly-attributed row, and a non-admin member is denied read access. Co-Authored-By: Claude Sonnet 5 --- public/app-runtime.js | 83 +++++++- .../20260922120000_order_audit_log.sql | 177 ++++++++++++++++++ 2 files changed, 257 insertions(+), 3 deletions(-) create mode 100644 supabase/migrations/20260922120000_order_audit_log.sql diff --git a/public/app-runtime.js b/public/app-runtime.js index a3503d9..ad3b1fd 100644 --- a/public/app-runtime.js +++ b/public/app-runtime.js @@ -4618,6 +4618,83 @@ window.SUN_LEGACY_CATALOG_V175=[]; })(); ; +/* ===== MODULE: order-audit-log.js ===== */ +/* Server-side journal of who created, edited or deleted an order, and when. + Written by the database itself (see sun_save_app_state in the 20260922120000 + migration), so it can't be spoofed or wiped by whoever made the change — + unlike the old per-browser "История изменений" list. Visible to owners and + administrators only. */ +(()=>{ + 'use strict'; + if(window.__sunOrderAuditLogCard)return;window.__sunOrderAuditLogCard=true; + const $=id=>document.getElementById(id); + const qa=(s,r=document)=>[...r.querySelectorAll(s)]; + const esc=window.SunSafe.escapeHTML; + const cloud=()=>window.SunCloudV2||null; + const client=()=>cloud()?.getClient?.()||null; + const workspace=()=>cloud()?.getWorkspace?.()||null; + const ACTION_LABELS={create:'Создан',update:'Изменён',delete:'Удалён'}; + const money=v=>{const n=Number(v);return Number.isFinite(n)?n.toLocaleString('ru-RU')+' ₽':''}; + + function canSee(){return String(workspace()?.role||'')==='admin'} + + function installStyle(){ + if($('sunOrderAuditStyle'))return; + const s=document.createElement('style');s.id='sunOrderAuditStyle';s.textContent=` + .sun-order-audit-list{display:grid;gap:0;margin-top:10px;border-top:1px solid #e7eaee} + .sun-order-audit-row{display:grid;grid-template-columns:96px 1fr auto;gap:10px;align-items:baseline;padding:9px 2px;border-bottom:1px dashed #e7eaee;font-size:12.5px} + .sun-order-audit-row small{display:block;color:#8a919b;font-size:10.5px} + .sun-order-audit-row b{color:#243b30} + .sun-order-audit-action{justify-self:end;padding:3px 8px;border-radius:999px;font-size:10.5px;font-weight:800;white-space:nowrap} + .sun-order-audit-action.create{background:#e7f4ea;color:#2f7a45} + .sun-order-audit-action.update{background:#fdf3de;color:#9a7016} + .sun-order-audit-action.delete{background:#fbe7e7;color:#a64040} + @media(max-width:640px){.sun-order-audit-row{grid-template-columns:1fr;gap:2px}.sun-order-audit-action{justify-self:start}} + `;document.head.appendChild(s); + } + + function row(item){ + const at=new Date(item.created_at); + const when=Number.isNaN(at.getTime())?'':at.toLocaleString('ru-RU',{day:'2-digit',month:'2-digit',year:'2-digit',hour:'2-digit',minute:'2-digit'}); + const d=item.details||{}; + const meta=[d.status?String(d.status):'',Number.isFinite(Number(d.total))?money(d.total):''].filter(Boolean).join(' · '); + return `
${esc(when)}
${esc(item.summary||('Заказ №'+item.order_id))}${esc(item.actor_name||'Сотрудник')}${item.actor_role?` · ${esc(item.actor_role)}`:''}${meta?` · ${esc(meta)}`:''}
${esc(ACTION_LABELS[item.action]||item.action)}
`; + } + + async function load(body){ + const c=client(),ws=workspace(); + if(!c||!ws?.id){body.innerHTML='

Доступно после входа в облачную рабочую базу.

';return} + body.innerHTML='

Загрузка…

'; + try{ + const {data,error}=await c.rpc('sun_list_order_audit',{p_workspace:ws.id,p_limit:200}); + if(error)throw error; + const rows=Array.isArray(data)?data:[]; + body.innerHTML=rows.length?`
${rows.map(row).join('')}
`:'

Изменений заказов пока нет.

'; + }catch(e){body.innerHTML=`

Не удалось загрузить журнал: ${esc(e.message||String(e))}

`;} + } + + function ensureCard(){ + if(!canSee()){$('sunOrderAuditLogCard')?.remove();return} + const grid=document.querySelector('#enterprise-settings .enterprise-grid');if(!grid)return; + let card=$('sunOrderAuditLogCard'); + if(!card){ + installStyle(); + card=document.createElement('section');card.className='enterprise-card wide';card.id='sunOrderAuditLogCard'; + card.innerHTML='

Журнал заказов

Кто и когда создал, изменил или удалил заказ — включая сотрудников с доступом к заказам. Запись ведётся на сервере, её нельзя стереть из браузера.

'; + const audit=qa(':scope > .enterprise-card',grid).find(x=>(x.querySelector('h2')?.textContent||'').trim()==='История изменений'); + window.SunSafe.insertBefore(grid,card,audit||null); + $('sunOrderAuditRefresh').onclick=()=>load($('sunOrderAuditBody')); + } + const body=$('sunOrderAuditBody');if(body&&!body.dataset.loaded){body.dataset.loaded='1';load(body)} + } + + document.addEventListener('click',e=>{const b=e.target.closest('header nav button');if(!b)return;if(String(b.dataset.navLabel||b.textContent||'').trim()==='Настройки')setTimeout(ensureCard,90)},true); + const host=$('enterprise-settings');if(host)new MutationObserver(()=>{if(host.classList.contains('on'))setTimeout(ensureCard,60)}).observe(host,{childList:true,subtree:true}); + window.addEventListener('sun:cloud-permissions-changed',()=>setTimeout(ensureCard,120)); + if($('enterprise-settings')?.classList.contains('on'))setTimeout(ensureCard,140); +})(); +; + /* ===== MODULE: saas-v16.js ===== */ /* Sun Catering SaaS subscriptions, plan entitlements and platform admin v16 */ (()=>{ @@ -5284,7 +5361,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; const qa=(sel,root=document)=>[...root.querySelectorAll(sel)]; const TAB_KEY='sunSettingsActiveTabV1'; const TABS=[ - {id:'account',label:'Аккаунт',hint:'Профиль, компания, тариф, пользователи и история изменений.'}, + {id:'account',label:'Аккаунт',hint:'Профиль, компания, тариф, пользователи, журнал заказов и история изменений.'}, {id:'appearance',label:'Оформление',hint:'Цвета интерфейса, боковой панели, готовые цветовые варианты и цвета оплаты заказов.'}, {id:'offer',label:'Предложение',hint:'Шаблон и содержимое предложения клиенту.'}, {id:'orders',label:'Заказы',hint:'Статусы заказов.'}, @@ -5328,8 +5405,8 @@ window.SUN_LEGACY_CATALOG_V175=[]; function heading(card){return String(card?.querySelector(':scope > h2')?.textContent||card?.querySelector('h2')?.textContent||'').trim()} function classify(card){ const id=String(card?.id||''),h=heading(card); - if(id==='sunCloudV2Card'||id==='sunSaaSSettingsCardV16'||/^(Профиль и аккаунт|Облачная синхронизация|Тариф и подписка|Пользователи и роли|История изменений)$/.test(h)){ - const order=id==='sunCloudV2Card'||/^(Профиль и аккаунт|Облачная синхронизация)$/.test(h)?10:id==='sunSaaSSettingsCardV16'||h==='Тариф и подписка'?20:h==='Пользователи и роли'?30:90; + if(id==='sunCloudV2Card'||id==='sunSaaSSettingsCardV16'||id==='sunOrderAuditLogCard'||/^(Профиль и аккаунт|Облачная синхронизация|Тариф и подписка|Пользователи и роли|Журнал заказов|История изменений)$/.test(h)){ + const order=id==='sunCloudV2Card'||/^(Профиль и аккаунт|Облачная синхронизация)$/.test(h)?10:id==='sunSaaSSettingsCardV16'||h==='Тариф и подписка'?20:h==='Пользователи и роли'?30:id==='sunOrderAuditLogCard'||h==='Журнал заказов'?80:90; return['account',order]; } if(id==='sunBrandThemeSettingsCard'||/^(Цвета интерфейса|Настройки интерфейса|Оформление интерфейса)$/.test(h))return['appearance',10]; diff --git a/supabase/migrations/20260922120000_order_audit_log.sql b/supabase/migrations/20260922120000_order_audit_log.sql new file mode 100644 index 0000000..1cc1228 --- /dev/null +++ b/supabase/migrations/20260922120000_order_audit_log.sql @@ -0,0 +1,177 @@ +-- 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';