feat: server-side order audit log for company owners
Some checks failed
Caterium QA / qa (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
pavlov346346-source 2026-09-22 18:56:39 +03:00
parent b25a2e0d0c
commit ab6d41ba0e
2 changed files with 257 additions and 3 deletions

View File

@ -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 `<div class="sun-order-audit-row"><small>${esc(when)}</small><div><b>${esc(item.summary||('Заказ №'+item.order_id))}</b><small>${esc(item.actor_name||'Сотрудник')}${item.actor_role?` · ${esc(item.actor_role)}`:''}${meta?` · ${esc(meta)}`:''}</small></div><span class="sun-order-audit-action ${esc(item.action)}">${esc(ACTION_LABELS[item.action]||item.action)}</span></div>`;
}
async function load(body){
const c=client(),ws=workspace();
if(!c||!ws?.id){body.innerHTML='<p class="hint">Доступно после входа в облачную рабочую базу.</p>';return}
body.innerHTML='<p class="hint">Загрузка…</p>';
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?`<div class="sun-order-audit-list">${rows.map(row).join('')}</div>`:'<p class="hint">Изменений заказов пока нет.</p>';
}catch(e){body.innerHTML=`<p class="hint">Не удалось загрузить журнал: ${esc(e.message||String(e))}</p>`;}
}
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='<h2>Журнал заказов</h2><p class="hint">Кто и когда создал, изменил или удалил заказ — включая сотрудников с доступом к заказам. Запись ведётся на сервере, её нельзя стереть из браузера.</p><div class="actions"><button class="outline" id="sunOrderAuditRefresh" type="button">Обновить</button></div><div id="sunOrderAuditBody"></div>';
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];

View File

@ -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';