diff --git a/docs/RECOVERY-20260917.md b/docs/RECOVERY-20260917.md new file mode 100644 index 0000000..bf1c8cc --- /dev/null +++ b/docs/RECOVERY-20260917.md @@ -0,0 +1,27 @@ +# Caterium recovery — 17 September 2026 + +Target: `caterium-fresh`, Supabase reference `usfjwhztqoopzzfmfbis`, region `eu-central-1`. The retired project was deleted; this recovery creates an empty application. No old users, company data, or passwords are imported. The `ai-staff` project is outside this recovery. + +## Database + +The supplied production archive and Git history did not contain the v17 normalized-state foundation or the complete promo/developer RPC layer. These were reconstructed from the current frontend contracts and the retained SQL. The v17.8 developer panel and v17.9 developer settings scripts were recovered from the local desktop source. + +`supabase/migrations/20260917150000_fresh_caterium.sql` is the complete transaction applied to the empty target database. The migration rejects an already initialized Caterium schema. Its 25 inputs, in execution order, are listed in `tests/recovery/manifest.json`; `npm run build:recovery` reproduces the bundle. Further production changes must use a new migration rather than replaying this baseline. + +Deployment verification found 26 public tables, RLS enabled on all 26, two Caterium cron jobs, zero workspaces and zero auth users. The migration is recorded in `supabase_migrations.schema_migrations`. + +The recovered layer includes revision conflict detection, permission-filtered snapshots, normalized orders/catalog/clients, backup and automation RPCs, subscription onboarding, promo codes and developer aliases. Recovery also fixes ambiguous client upserts, enforces confirmed owner email, and avoids an administrator-only audit helper during ordinary owner onboarding. Developer operations enforce AAL2. + +`npm run test:db` uses a disposable local PGlite database with mocked Supabase auth/storage/realtime/cron infrastructure. It exercises owner onboarding, save/read, optimistic conflicts, backups, employee membership, cross-company denial, viewer write denial, MFA restrictions and developer RPCs. It does not replace a real signed-in production acceptance test or verify Supabase email delivery. + +## Application and hosting + +The app and PHP proxy now target the new project. Only the public publishable key is present in the frontend. Existing browser configuration for the retired backend is archived locally and reset; old local workspace data remains isolated through the existing workspace-switch mechanism. A new service-worker cache and asset version deliver the change to installed clients. + +GitHub `main` runs QA and promotes the tested commit to `production`. Timeweb sync deploys `public/` to `~/caterium-app/public_html`. The separately hosted PHP proxy is deployed to `~/public_html/api-proxy/index.php`; preserve a copy in `~/.caterium-deploy/manual-backups` before replacement. + +Both Edge Functions are deployed to the new Supabase project: `caterium-create-employee` and `caterium-platform-auth-admin`. JWT verification remains enabled; the platform auth function additionally checks the MFA-protected developer dashboard RPC before privileged operations. Supabase Site URL is `https://app.caterium.ru`, with `https://app.caterium.ru/**` allowed for redirects. Email confirmation remains enabled. + +## First use + +All old accounts were deleted with the old database. A new owner account must register, confirm its email and set a new password. The first platform administrator and any onboarding promo must be provisioned for the user-confirmed owner identity; there is no universal recovery password or open administrator bootstrap. A real login, email delivery, and company onboarding must be checked once that identity is available. diff --git a/docs/release-manifest.json b/docs/release-manifest.json index d26ade7..e7a2088 100644 --- a/docs/release-manifest.json +++ b/docs/release-manifest.json @@ -11,7 +11,7 @@ "serverReady": true, "workspaceAutoDiscovery": true, "invitesTemporarilyDisabled": false, - "pwaCache": "v81-20260912-account-center-loader", + "pwaCache": "v82-20260917-caterium-fresh", "fullOfferDescriptions": true, "dynamicOfferRows": true, "pdfOfferDescriptionFix": true, @@ -381,5 +381,7 @@ "clientServerSnapshotRpc": "sun_v17_clients_snapshot_v1773", "clientLegacyFallback": true, "clientOrderMetricsSource": "legacy orders verified locally", - "clientServerCache": "cateriumClientsServerV1773" + "clientServerCache": "cateriumClientsServerV1773", + "supabaseProjectRef": "usfjwhztqoopzzfmfbis", + "recoveryMigration": "20260917150000_fresh_caterium" } diff --git a/ops/sql/SUPABASE-FRESH-FINAL-GUARDS.sql b/ops/sql/SUPABASE-FRESH-FINAL-GUARDS.sql new file mode 100644 index 0000000..02ba82e --- /dev/null +++ b/ops/sql/SUPABASE-FRESH-FINAL-GUARDS.sql @@ -0,0 +1,52 @@ +-- Explicit API grants, with RLS on every application table. +do $$ declare f record;begin + for f in select p.oid::regprocedure as signature from pg_proc p join pg_namespace n on n.oid=p.pronamespace + where n.nspname='public' and (p.proname like 'sun_%' or p.proname like 'caterium_%') loop + execute format('revoke execute on function %s from public,anon',f.signature); + end loop; +end $$; +grant execute on function public.caterium_trial_promo_preview(text,text),public.sun_invite_preview_v27(uuid) to anon,authenticated; +grant execute on function public.sun_v17_log_error(uuid,text,text,text,text,text,jsonb) to authenticated; + +-- No direct full-state writes/reads: retain the subscription/permission checks. +revoke all on public.sun_app_state from anon,authenticated; + +-- Enforce MFA even if a caller uses an older public platform RPC name. +do $$ declare f record;definition text;begin + for f in select p.oid from pg_proc p join pg_namespace n on n.oid=p.pronamespace + where n.nspname='public' and p.proname like 'sun_platform_%' and p.prosrc like '%if not public.sun_is_platform_admin() then raise exception ''Platform administrator required''; end if;%' loop + definition:=replace(pg_get_functiondef(f.oid),'if not public.sun_is_platform_admin() then raise exception ''Platform administrator required''; end if;','perform public.sun_require_platform_admin_aal2();'); + execute definition; + end loop; +end $$; + +create or replace function public.sun_v17_entity_snapshot(p_workspace uuid) +returns jsonb language plpgsql stable security definer set search_path=public as $$ +declare is_admin boolean:=public.sun_is_platform_admin(); +begin + if is_admin then perform public.sun_require_platform_admin_aal2(); + elsif public.sun_member_role(p_workspace) is null then raise exception 'Access denied'; end if; + if not is_admin and public.sun_subscription_access_mode(p_workspace)='blocked' then raise exception 'Подписка закончилась'; end if; + return jsonb_build_object( + 'orders',case when is_admin or (public.sun_has_permission(p_workspace,'orders.view') and public.sun_workspace_has_feature(p_workspace,'orders')) then coalesce((select jsonb_agg(jsonb_build_object('id',order_id,'version',version,'data',data,'updated_at',updated_at) order by order_id) from public.sun_v17_orders where workspace_id=p_workspace),'[]') else '[]'::jsonb end, + 'catalog',case when is_admin or (public.sun_has_permission(p_workspace,'catalog.view') and public.sun_workspace_has_feature(p_workspace,'catalog_view')) then coalesce((select jsonb_agg(jsonb_build_object('id',item_id,'version',version,'data',data,'updated_at',updated_at) order by item_id) from public.sun_v17_catalog_items where workspace_id=p_workspace),'[]') else '[]'::jsonb end, + 'meta',coalesce((select to_jsonb(m) from public.sun_v17_workspace_meta m where workspace_id=p_workspace),'{}')); +end $$; + +-- Authenticated roles can invoke public API guards but cannot invoke snapshot internals. +grant execute on function public.sun_v17_entity_snapshot(uuid) to authenticated; +revoke all on function public.sun_v17_build_snapshot(uuid),public.sun_require_platform_admin_aal2() from public,anon,authenticated; + +-- Keep owner-registration metadata in the same typed format as the sync client. +do $$declare definition text;begin + definition:=pg_get_functiondef('public.sun_create_workspace(text)'::regprocedure); + definition:=replace(definition,'''sunCompanyProfileV1'',v_profile::text','''sunCompanyProfileV1'',jsonb_build_object(''t'',''j'',''v'',v_profile)'); + execute definition; +end $$; + +-- Match client visibility to the same granular permission as state reads. +do $$declare definition text;begin + definition:=pg_get_functiondef('public.sun_v17_clients_snapshot_v1773(uuid)'::regprocedure); + definition:=replace(definition,'if not public.sun_workspace_has_feature(p_workspace,''clients'') then','if not public.sun_has_permission(p_workspace,''clients.view'') or public.sun_subscription_access_mode(p_workspace)=''blocked'' or not public.sun_workspace_has_feature(p_workspace,''clients'') then'); + execute definition; +end $$; diff --git a/ops/sql/SUPABASE-FRESH-PROMOS-AND-ADMIN.sql b/ops/sql/SUPABASE-FRESH-PROMOS-AND-ADMIN.sql new file mode 100644 index 0000000..4ef0fa3 --- /dev/null +++ b/ops/sql/SUPABASE-FRESH-PROMOS-AND-ADMIN.sql @@ -0,0 +1,112 @@ +-- Missing production RPC contracts reconstructed for the empty Caterium project. +create table public.caterium_trial_promos ( + id uuid primary key default gen_random_uuid(),code text not null unique, + client_email text,trial_days integer not null default 14 check(trial_days between 1 and 365), + plan_id text not null default 'full' references public.sun_plans(id), + max_uses integer not null default 1 check(max_uses between 1 and 10000),use_count integer not null default 0, + is_active boolean not null default true,valid_until timestamptz,note text, + created_by uuid references auth.users(id) on delete set null,created_at timestamptz not null default now(),updated_at timestamptz not null default now() +); +create table public.caterium_trial_redemptions ( + id uuid primary key default gen_random_uuid(),promo_id uuid not null references public.caterium_trial_promos(id), + workspace_id uuid not null unique references public.sun_workspaces(id) on delete cascade, + user_id uuid not null unique references auth.users(id) on delete cascade,email text,trial_ends_at timestamptz, + created_at timestamptz not null default now() +); +alter table public.caterium_trial_promos enable row level security; +alter table public.caterium_trial_redemptions enable row level security; +revoke all on public.caterium_trial_promos,public.caterium_trial_redemptions from anon,authenticated; +create function public.caterium_normalize_trial_code(p_code text) returns text language sql immutable set search_path=public as $$select upper(regexp_replace(trim(coalesce(p_code,'')),'[[:space:]]','','g'))$$; +create function public.caterium_trial_promo_preview(p_code text,p_email text default null) +returns jsonb language plpgsql stable security definer set search_path=public as $$ +declare p public.caterium_trial_promos%rowtype; +begin + select * into p from public.caterium_trial_promos where code=public.caterium_normalize_trial_code(p_code); + if not found or not p.is_active or (p.valid_until is not null and p.valid_until<=now()) or p.use_count>=p.max_uses + or (p.client_email is not null and p.client_email<>lower(trim(coalesce(p_email,'')))) then + return jsonb_build_object('valid',false,'reason','Промокод недействителен для этого email или срок его действия истёк'); + end if; + return jsonb_build_object('valid',true,'trial_days',p.trial_days,'plan',p.plan_id); +end $$; +create function public.sun_dev_create_trial_promo(p_code text default null,p_email text default null,p_trial_days integer default 14,p_valid_days integer default 7,p_max_uses integer default 1,p_plan text default 'full',p_note text default null) +returns jsonb language plpgsql security definer set search_path=public as $$ +declare p public.caterium_trial_promos%rowtype; c text; +begin + perform public.sun_require_platform_admin_aal2(); + c:=coalesce(nullif(public.caterium_normalize_trial_code(p_code),''),'CTM-'||upper(replace(gen_random_uuid()::text,'-',''))::varchar(16)); + if c !~ '^[A-Z0-9-]{3,32}$' then raise exception 'Некорректный промокод'; end if; + if p_valid_days not between 1 and 365 then raise exception 'Некорректный срок'; end if; + insert into public.caterium_trial_promos(code,client_email,trial_days,valid_until,max_uses,plan_id,note,created_by) + values(c,nullif(lower(trim(p_email)),''),p_trial_days,now()+make_interval(days=>p_valid_days),p_max_uses,p_plan,p_note,auth.uid()) returning * into p; + perform public.sun_platform_log_event('trial_promo.create',null,null,jsonb_build_object('promo_id',p.id)); + return jsonb_build_object('promo_id',p.id,'code',p.code,'trial_days',p.trial_days,'valid_until',p.valid_until); +end $$; +create function public.sun_dev_list_trial_promos(p_limit integer default 300) +returns table(promo_id uuid,code text,client_email text,trial_days integer,valid_until timestamptz,max_uses integer,use_count integer,is_active boolean,last_redeemed_at timestamptz,last_workspace_name text,last_redeemed_email text) +language plpgsql stable security definer set search_path=public as $$ +begin + perform public.sun_require_platform_admin_aal2(); + return query select p.id,p.code,p.client_email,p.trial_days,p.valid_until,p.max_uses,p.use_count,p.is_active,r.created_at,w.name,r.email + from public.caterium_trial_promos p left join lateral (select x.* from public.caterium_trial_redemptions x where x.promo_id=p.id order by x.created_at desc limit 1) r on true + left join public.sun_workspaces w on w.id=r.workspace_id order by p.created_at desc limit greatest(1,least(coalesce(p_limit,300),1000)); +end $$; +create function public.sun_dev_set_trial_promo_active(p_promo uuid,p_active boolean) +returns void language plpgsql security definer set search_path=public as $$ +begin + perform public.sun_require_platform_admin_aal2(); + update public.caterium_trial_promos set is_active=p_active,updated_at=now() where id=p_promo; + if not found then raise exception 'Промокод не найден'; end if; + perform public.sun_platform_log_event('trial_promo.set_active',null,null,jsonb_build_object('promo_id',p_promo,'active',p_active)); +end $$; + +create function public.caterium_platform_create_company(p_name text,p_owner_email text default null,p_plan text default 'full',p_days integer default 30,p_mode text default 'empty') +returns jsonb language plpgsql security definer set search_path=public,auth as $$ +declare v_ws uuid; v_owner uuid; v_email text:=nullif(lower(trim(p_owner_email)),''); v_token uuid; +begin + perform public.sun_require_platform_admin_aal2(); + if p_days not between 1 and 3650 then raise exception 'Invalid subscription duration'; end if; + select id into v_owner from auth.users where lower(email)=v_email and email_confirmed_at is not null; + insert into public.sun_workspaces(name,created_by) values(coalesce(nullif(trim(p_name),''),'Новая компания'),coalesce(v_owner,auth.uid())) returning id into v_ws; + if v_owner is not null then + insert into public.sun_workspace_members(workspace_id,user_id,role,is_active,permissions,display_name) + values(v_ws,v_owner,'admin',true,public.sun_role_default_permissions('admin'),split_part(v_email,'@',1)); + elsif v_email is not null then + insert into public.caterium_company_owner_invites(workspace_id,email) values(v_ws,v_email) returning token into v_token; + end if; + insert into public.sun_app_state(workspace_id,client_id) values(v_ws,'platform-bootstrap'); + insert into public.sun_workspace_subscriptions(workspace_id,plan_id,status,current_period_start,current_period_end,grace_until,source) + values(v_ws,p_plan,'active',now(),now()+make_interval(days=>p_days),now()+make_interval(days=>p_days+7),'platform'); + return jsonb_build_object('workspace_id',v_ws,'owner_user_id',v_owner,'owner_email',v_email,'owner_invite_token',v_token); +end $$; + +-- Recover the sun_dev_* names used by the client, preserving the retained +-- server implementation and requiring AAL2 at every platform boundary. +do $recovery$ +declare entry record; f record; call_args text; command text; +begin + for entry in select * from (values + ('sun_dev_dashboard','sun_platform_dashboard'),('sun_dev_list_activity','sun_platform_list_activity'), + ('sun_dev_list_companies','sun_platform_list_companies_v22'),('sun_dev_list_users','sun_platform_list_users_v22'), + ('sun_dev_list_errors','sun_platform_list_errors_v22'),('sun_dev_support_snapshot','sun_platform_support_snapshot'), + ('sun_dev_workspace_diagnostics','sun_platform_workspace_diagnostics'),('sun_dev_list_workspace_features','sun_platform_list_workspace_features'), + ('sun_dev_log_event','sun_platform_log_event'),('sun_dev_set_plan_feature','sun_platform_set_plan_feature'), + ('sun_dev_set_plan_max_members','sun_platform_set_plan_max_members'),('sun_dev_seed_workspace_catalog','sun_platform_seed_workspace_catalog'), + ('sun_dev_reset_feature_override','sun_platform_reset_feature_override'),('sun_dev_create_company','sun_platform_create_company_v22'), + ('sun_dev_set_subscription','sun_platform_set_subscription'),('sun_dev_set_feature_override','sun_platform_set_feature_override') + ) names(alias_name,source_name) loop + select p.*,pg_get_function_arguments(p.oid) as args,pg_get_function_result(p.oid) as result into strict f + from pg_proc p join pg_namespace n on n.oid=p.pronamespace where n.nspname='public' and p.proname=entry.source_name; + select coalesce(string_agg('$'||i,',' order by i),'') into call_args from generate_series(1,f.pronargs) i; + command:=case when f.proretset then 'return query select * from' when f.prorettype='void'::regtype then 'perform' else 'return' end; + execute format('create function public.%I(%s) returns %s language plpgsql security definer set search_path=public,auth as $body$ begin perform public.sun_require_platform_admin_aal2(); %s public.%I(%s); end $body$',entry.alias_name,f.args,f.result,command,entry.source_name,call_args); + end loop; +end $recovery$; + +revoke all on function public.caterium_normalize_trial_code(text),public.caterium_trial_promo_preview(text,text),public.caterium_platform_create_company(text,text,text,integer,text) from public,anon,authenticated; +grant execute on function public.caterium_trial_promo_preview(text,text) to anon,authenticated; +do $$declare f record;begin + for f in select p.oid::regprocedure as signature from pg_proc p join pg_namespace n on n.oid=p.pronamespace where n.nspname='public' and p.proname like 'sun_dev_%' loop + execute format('revoke all on function %s from public,anon',f.signature); + execute format('grant execute on function %s to authenticated',f.signature); + end loop; +end $$; diff --git a/ops/sql/SUPABASE-FRESH-V17-FOUNDATION.sql b/ops/sql/SUPABASE-FRESH-V17-FOUNDATION.sql new file mode 100644 index 0000000..479de28 --- /dev/null +++ b/ops/sql/SUPABASE-FRESH-V17-FOUNDATION.sql @@ -0,0 +1,136 @@ +-- Reconstructed from the production client and retained SQL, 2026-09-17. +-- Empty-project recovery only; original v17 foundation was not in Git history. +create table public.sun_v17_orders ( + workspace_id uuid not null references public.sun_workspaces(id) on delete cascade, + order_id text not null, data jsonb not null, version bigint not null default 1, + created_at timestamptz not null default now(), updated_at timestamptz not null default now(), + updated_by uuid references auth.users(id) on delete set null, primary key(workspace_id,order_id) +); +create table public.sun_v17_catalog_items ( + workspace_id uuid not null references public.sun_workspaces(id) on delete cascade, + item_id text not null, data jsonb not null, version bigint not null default 1, + created_at timestamptz not null default now(), updated_at timestamptz not null default now(), + updated_by uuid references auth.users(id) on delete set null, primary key(workspace_id,item_id) +); +create table public.sun_v17_clients ( + workspace_id uuid not null references public.sun_workspaces(id) on delete cascade, + client_key text not null,name text,phone text,latest_address text,data jsonb not null default '{}', + version bigint not null default 1,created_at timestamptz not null default now(),updated_at timestamptz not null default now(), + primary key(workspace_id,client_key) +); +create table public.sun_v17_settings ( + workspace_id uuid not null references public.sun_workspaces(id) on delete cascade, + key text not null,value jsonb,version bigint not null default 1,updated_at timestamptz not null default now(), + primary key(workspace_id,key) +); +create table public.sun_v17_workspace_meta ( + workspace_id uuid primary key references public.sun_workspaces(id) on delete cascade, + schema_version integer not null default 17,last_backup_on date,legacy_revision bigint not null default 0, + migrated_at timestamptz not null default now(),updated_at timestamptz not null default now() +); +create table public.sun_v17_change_events ( + id bigint generated by default as identity primary key, + workspace_id uuid not null references public.sun_workspaces(id) on delete cascade, + entity text not null,entity_key text,operation text not null,version bigint,client_id text, + created_by uuid references auth.users(id) on delete set null,created_at timestamptz not null default now() +); +create index sun_v17_changes_workspace_id_idx on public.sun_v17_change_events(workspace_id,id); +create table public.sun_v17_backups ( + id uuid primary key default gen_random_uuid(),workspace_id uuid not null references public.sun_workspaces(id) on delete cascade, + kind text not null,label text not null default '',snapshot jsonb not null, + created_by uuid references auth.users(id) on delete set null,created_at timestamptz not null default now() +); +create index sun_v17_backups_workspace_created_idx on public.sun_v17_backups(workspace_id,created_at desc); +create table public.sun_v17_error_events ( + id uuid primary key default gen_random_uuid(),workspace_id uuid references public.sun_workspaces(id) on delete cascade, + user_id uuid references auth.users(id) on delete set null,client_id text,app_version text,level text,message text, + stack text,context jsonb not null default '{}',created_at timestamptz not null default now() +); +create index sun_v17_errors_workspace_created_idx on public.sun_v17_error_events(workspace_id,created_at desc); +create table public.caterium_company_owner_invites ( + token uuid primary key default gen_random_uuid(),workspace_id uuid not null references public.sun_workspaces(id) on delete cascade, + email text not null,created_at timestamptz not null default now(),expires_at timestamptz not null default now()+interval '7 days', + used_at timestamptz,used_by uuid references auth.users(id) on delete set null +); + +do $$ declare t text; begin + foreach t in array array['sun_v17_orders','sun_v17_catalog_items','sun_v17_clients','sun_v17_settings','sun_v17_workspace_meta','sun_v17_change_events','sun_v17_backups','sun_v17_error_events','caterium_company_owner_invites'] loop + execute format('alter table public.%I enable row level security',t); + execute format('revoke all on public.%I from anon,authenticated',t); + end loop; +end $$; +-- Browser data access uses the permission-checked RPCs. Realtime only reveals an event. +grant select on public.sun_v17_change_events to authenticated; +create policy sun_v17_change_read on public.sun_v17_change_events for select to authenticated using(public.sun_member_role(workspace_id) is not null); +alter publication supabase_realtime add table public.sun_v17_change_events; + +create function public.sun_my_workspaces() +returns table(id uuid,name text,role text,display_name text,is_active boolean,permissions jsonb) +language sql stable security definer set search_path=public as $$ + select w.id,w.name,m.role,m.display_name,m.is_active,public.sun_role_default_permissions(m.role)||coalesce(m.permissions,'{}') + from public.sun_workspace_members m join public.sun_workspaces w on w.id=m.workspace_id + where m.user_id=auth.uid() and m.is_active order by w.created_at,w.id +$$; + +create function public.sun_v17_mirror_legacy(p_workspace uuid,p_payload jsonb,p_client_id text default null) +returns void language plpgsql security definer set search_path=public as $$ +declare canonical jsonb; r record; v_data jsonb; v_ids text[]; v_key text; v_rev bigint; +begin + if public.sun_member_role(p_workspace) is null and not public.sun_is_platform_admin() then raise exception 'Access denied'; end if; + perform 1 from public.sun_workspaces where id=p_workspace for update; + select payload,revision into canonical,v_rev from public.sun_app_state where workspace_id=p_workspace; + if canonical is null then return; end if; + -- Always mirror the validated server row. The supplied legacy argument is never trusted. + for r in select * from (values ('sunOrders','sun_v17_orders','order_id','order'),('sunBoxes','sun_v17_catalog_items','item_id','catalog')) as x(storage_key,table_name,id_column,entity) loop + v_data:=coalesce(canonical#>array['storage',r.storage_key,'v'],'[]'::jsonb); + if jsonb_typeof(v_data)<>'array' then raise exception 'Invalid entity array: %',r.storage_key; end if; + if exists(select 1 from jsonb_array_elements(v_data) e where nullif(e->>'id','') is null) then raise exception 'Entity ID is required'; end if; + if (select count(*) from jsonb_array_elements(v_data))<>(select count(distinct e->>'id') from jsonb_array_elements(v_data) e) then raise exception 'Duplicate entity ID'; end if; + select coalesce(array_agg(e->>'id'),'{}') into v_ids from jsonb_array_elements(v_data) e; + execute format('with gone as (delete from public.%I where workspace_id=$1 and not (%I=any($2)) returning %I,version) insert into public.sun_v17_change_events(workspace_id,entity,entity_key,operation,version,client_id,created_by) select $1,$3,%I,''delete'',version+1,$4,auth.uid() from gone',r.table_name,r.id_column,r.id_column,r.id_column) using p_workspace,v_ids,r.entity,p_client_id; + execute format('with saved as (insert into public.%I as dest(workspace_id,%I,data,updated_by) select $1,e->>''id'',e,auth.uid() from jsonb_array_elements($2) e on conflict(workspace_id,%I) do update set data=excluded.data,version=dest.version+1,updated_at=now(),updated_by=auth.uid() where dest.data is distinct from excluded.data returning %I,version) insert into public.sun_v17_change_events(workspace_id,entity,entity_key,operation,version,client_id,created_by) select $1,$3,%I,''upsert'',version,$4,auth.uid() from saved',r.table_name,r.id_column,r.id_column,r.id_column,r.id_column) using p_workspace,v_data,r.entity,p_client_id; + end loop; + delete from public.sun_v17_settings where workspace_id=p_workspace and not (canonical->'storage' ? key); + insert into public.sun_v17_settings as dest(workspace_id,key,value) + select p_workspace,key,value from jsonb_each(coalesce(canonical->'storage','{}')) where key not in ('sunOrders','sunBoxes') + on conflict(workspace_id,key) do update set value=excluded.value,version=dest.version+1,updated_at=now() where dest.value is distinct from excluded.value; + insert into public.sun_v17_workspace_meta(workspace_id,legacy_revision) values(p_workspace,v_rev) + on conflict(workspace_id) do update set legacy_revision=excluded.legacy_revision,updated_at=now(); +end $$; + +create function public.sun_save_app_state_v17(p_workspace uuid,p_payload jsonb,p_client_id text,p_expected_revision bigint default null) +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 current_revision bigint; +begin + if public.sun_member_role(p_workspace) is null then raise exception 'Access denied'; end if; + perform 1 from public.sun_workspaces w where w.id=p_workspace for update; + select s.revision into current_revision from public.sun_app_state s where s.workspace_id=p_workspace; + if p_expected_revision is not null and coalesce(current_revision,0)<>p_expected_revision then + raise exception using errcode='40001',message=format('SUN_CONFLICT expected=%s actual=%s',p_expected_revision,coalesce(current_revision,0)); + end if; + perform public.sun_save_app_state(p_workspace,p_payload,p_client_id); + perform public.sun_v17_mirror_legacy(p_workspace,p_payload,p_client_id); + return query select * from public.sun_fetch_app_state(p_workspace); +end $$; + +create function public.sun_v17_build_snapshot(p_workspace uuid) +returns jsonb language sql stable security definer set search_path=public as $$ + select jsonb_build_object('version',17,'legacy',payload,'revision',revision,'created_at',now()) from public.sun_app_state where workspace_id=p_workspace +$$; +create function public.sun_v17_prune_backups(p_workspace uuid,p_keep integer default 30) +returns integer language plpgsql security definer set search_path=public as $$ +declare n integer; +begin + if public.sun_member_role(p_workspace) is null and not public.sun_is_platform_admin() then raise exception 'Access denied'; end if; + delete from public.sun_v17_backups where workspace_id=p_workspace and kind='daily' and id in + (select id from public.sun_v17_backups where workspace_id=p_workspace and kind='daily' order by created_at desc offset greatest(30,coalesce(p_keep,30))); + get diagnostics n=row_count; return n; +end $$; + +create function public.sun_workspace_access_mode_internal_v28(p_workspace uuid) returns text language sql stable security definer set search_path=public as $$select public.sun_subscription_access_mode(p_workspace)$$; +create function public.sun_workspace_feature_internal_v28(p_workspace uuid,p_feature text) returns boolean language sql stable security definer set search_path=public as $$select public.sun_workspace_has_feature(p_workspace,p_feature)$$; + +revoke all on function public.sun_v17_build_snapshot(uuid),public.sun_workspace_access_mode_internal_v28(uuid),public.sun_workspace_feature_internal_v28(uuid,text) from public,anon,authenticated; +revoke all on function public.sun_my_workspaces(),public.sun_save_app_state_v17(uuid,jsonb,text,bigint),public.sun_v17_mirror_legacy(uuid,jsonb,text),public.sun_v17_prune_backups(uuid,integer) from public,anon; +grant execute on function public.sun_my_workspaces(),public.sun_save_app_state_v17(uuid,jsonb,text,bigint),public.sun_v17_mirror_legacy(uuid,jsonb,text),public.sun_v17_prune_backups(uuid,integer) to authenticated; diff --git a/ops/sql/SUPABASE-V17.7.2-CLIENTS-FOUNDATION.sql b/ops/sql/SUPABASE-V17.7.2-CLIENTS-FOUNDATION.sql index da71878..32f8ee5 100644 --- a/ops/sql/SUPABASE-V17.7.2-CLIENTS-FOUNDATION.sql +++ b/ops/sql/SUPABASE-V17.7.2-CLIENTS-FOUNDATION.sql @@ -52,7 +52,7 @@ begin insert into public.sun_v17_clients(workspace_id,client_key,name,phone,latest_address,data,version,created_at,updated_at) values(p_workspace,p_client_key,next_name,next_phone,next_address,p_profile,1,now(),now()) - on conflict(workspace_id,client_key) do update set + on conflict on constraint sun_v17_clients_pkey do update set name=coalesce(excluded.name,sun_v17_clients.name), phone=coalesce(excluded.phone,sun_v17_clients.phone), latest_address=coalesce(excluded.latest_address,sun_v17_clients.latest_address), diff --git a/ops/sql/SUPABASE-V17.8-DEVELOPER-PANEL.sql b/ops/sql/SUPABASE-V17.8-DEVELOPER-PANEL.sql new file mode 100644 index 0000000..edcec80 --- /dev/null +++ b/ops/sql/SUPABASE-V17.8-DEVELOPER-PANEL.sql @@ -0,0 +1,118 @@ +-- Sun Catering v17.8 developer-panel additions. +-- Applied to the current project on 2026-09-01. + +create or replace function public.sun_platform_list_users() +returns table( + user_id uuid, + email text, + last_sign_in_at timestamptz, + created_at timestamptz, + workspace_id uuid, + workspace_name text, + role text, + display_name text, + is_active boolean, + is_platform_admin boolean +) +language plpgsql +stable +security definer +set search_path='public' +as $$ +begin + if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if; + return query + select u.id,u.email::text,u.last_sign_in_at,u.created_at,m.workspace_id,w.name,m.role,m.display_name,m.is_active, + exists(select 1 from public.sun_platform_admins p where p.user_id=u.id) + from auth.users u + left join public.sun_workspace_members m on m.user_id=u.id + left join public.sun_workspaces w on w.id=m.workspace_id + order by coalesce(w.name,''),coalesce(m.display_name,u.email),u.email; +end; +$$; +revoke all on function public.sun_platform_list_users() from public; +grant execute on function public.sun_platform_list_users() to authenticated; + +create or replace function public.sun_v17_create_backup(p_workspace uuid, p_kind text default 'manual'::text, p_label text default ''::text) +returns uuid +language plpgsql +security definer +set search_path='public' +as $$ +declare v_id uuid; v_kind text:=lower(coalesce(p_kind,'manual')); +begin + if public.sun_member_role(p_workspace) is null and not public.sun_is_platform_admin() then raise exception 'Access denied'; end if; + if v_kind not in ('daily','manual','pre_restore','migration') then raise exception 'Invalid backup kind'; end if; + if v_kind<>'daily' and not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if; + if v_kind='daily' and exists(select 1 from public.sun_v17_backups where workspace_id=p_workspace and kind='daily' and created_at::date=current_date) then + select id into v_id from public.sun_v17_backups where workspace_id=p_workspace and kind='daily' and created_at::date=current_date order by created_at desc limit 1; + return v_id; + end if; + insert into public.sun_v17_backups(workspace_id,kind,label,snapshot,created_by) + values(p_workspace,v_kind,left(coalesce(p_label,''),240),public.sun_v17_build_snapshot(p_workspace),auth.uid()) returning id into v_id; + insert into public.sun_v17_workspace_meta(workspace_id,last_backup_on,updated_at) values(p_workspace,current_date,now()) + on conflict(workspace_id) do update set last_backup_on=current_date,updated_at=now(); + return v_id; +end; +$$; + +create or replace function public.sun_v17_restore_backup(p_workspace uuid, p_backup uuid) +returns void +language plpgsql +security definer +set search_path='public' +as $$ +declare s jsonb; legacy jsonb; +begin + if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if; + perform public.sun_v17_create_backup(p_workspace,'pre_restore','Автоматически перед восстановлением'); + select snapshot into s from public.sun_v17_backups where id=p_backup and workspace_id=p_workspace; + if s is null then raise exception 'Backup not found'; end if; + legacy:=s->'legacy'; + if legacy is null or jsonb_typeof(legacy)<>'object' then raise exception 'Backup has no legacy state'; end if; + perform public.sun_save_app_state(p_workspace,legacy,'backup-restore'); + perform public.sun_v17_mirror_legacy(p_workspace,legacy,'backup-restore'); + insert into public.sun_v17_change_events(workspace_id,entity,entity_key,operation,client_id,created_by) + values(p_workspace,'workspace','backup','restore','backup-restore',auth.uid()); +end; +$$; +revoke all on function public.sun_v17_create_backup(uuid,text,text) from public; +revoke all on function public.sun_v17_restore_backup(uuid,uuid) from public; +grant execute on function public.sun_v17_create_backup(uuid,text,text) to authenticated; +grant execute on function public.sun_v17_restore_backup(uuid,uuid) to authenticated; + +-- Company owner can attach an already registered account by email without invite codes. +create or replace function public.sun_owner_add_existing_member(p_workspace uuid,p_email text,p_role text default 'manager') +returns uuid +language plpgsql +security definer +set search_path='public' +as $$ +declare + v_user uuid; + v_role text:=lower(coalesce(p_role,'manager')); + v_display text; + v_max integer; + v_count integer; +begin + if auth.uid() is null then raise exception 'Authentication required'; end if; + if public.sun_member_role(p_workspace)<>'admin' and not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Нет права управлять сотрудниками'; end if; + if public.sun_subscription_access_mode(p_workspace)<>'full' then raise exception 'Подписка не позволяет изменять пользователей'; end if; + if not public.sun_workspace_has_feature(p_workspace,'users_manage') then raise exception 'Сотрудники недоступны на текущем тарифе'; end if; + if v_role not in ('manager','kitchen','courier','viewer') then raise exception 'Для сотрудника выберите рабочую роль'; end if; + select id,coalesce(nullif(raw_user_meta_data->>'name',''),split_part(email,'@',1)) into v_user,v_display + from auth.users where lower(email)=lower(trim(p_email)) limit 1; + if v_user is null then raise exception 'Аккаунт с таким email ещё не зарегистрирован'; end if; + select p.max_members into v_max from public.sun_workspace_subscriptions s join public.sun_plans p on p.id=s.plan_id where s.workspace_id=p_workspace; + select count(*)::int into v_count from public.sun_workspace_members where workspace_id=p_workspace and is_active=true; + if not exists(select 1 from public.sun_workspace_members where workspace_id=p_workspace and user_id=v_user and is_active=true) then + if v_max is not null and v_count>=v_max then raise exception 'Достигнут лимит сотрудников тарифа (%)',v_max; end if; + end if; + insert into public.sun_workspace_members(workspace_id,user_id,role,display_name,is_active,permissions,updated_at) + values(p_workspace,v_user,v_role,v_display,true,public.sun_role_default_permissions(v_role),now()) + on conflict(workspace_id,user_id) do update set role=excluded.role,display_name=coalesce(public.sun_workspace_members.display_name,excluded.display_name),is_active=true,permissions=excluded.permissions,updated_at=now(); + return v_user; +end; +$$; +revoke all on function public.sun_owner_add_existing_member(uuid,text,text) from public; +grant execute on function public.sun_owner_add_existing_member(uuid,text,text) to authenticated; diff --git a/ops/sql/SUPABASE-V17.8.2-OWNER-WORKSPACE-ONBOARDING.sql b/ops/sql/SUPABASE-V17.8.2-OWNER-WORKSPACE-ONBOARDING.sql index 0fb550c..8e5db52 100644 --- a/ops/sql/SUPABASE-V17.8.2-OWNER-WORKSPACE-ONBOARDING.sql +++ b/ops/sql/SUPABASE-V17.8.2-OWNER-WORKSPACE-ONBOARDING.sql @@ -22,6 +22,7 @@ declare v_trial_end timestamptz; begin if v_user is null then raise exception 'Authentication required'; end if; + if not exists(select 1 from auth.users where id=v_user and email_confirmed_at is not null) then raise exception 'Подтвердите email'; end if; if exists(select 1 from public.sun_workspace_members where user_id=v_user and is_active=true) then raise exception 'Аккаунт уже относится к компании'; end if; @@ -56,7 +57,8 @@ begin values(v_promo.id,v_workspace,v_user,v_email,v_trial_end); update public.caterium_trial_promos set use_count=use_count+1,updated_at=now() where id=v_promo.id; update auth.users set raw_user_meta_data=coalesce(raw_user_meta_data,'{}'::jsonb)-'promo_code'-'company_name' where id=v_user; - perform public.sun_platform_log_event('trial_promo.redeem',v_workspace,v_user,jsonb_build_object('promo_id',v_promo.id,'code',v_promo.code,'trial_days',v_promo.trial_days,'plan',v_promo.plan_id)); + insert into public.sun_platform_audit_events(actor_user_id,action,target_workspace_id,target_user_id,details) + values(v_user,'trial_promo.redeem',v_workspace,v_user,jsonb_build_object('promo_id',v_promo.id,'code',v_promo.code,'trial_days',v_promo.trial_days,'plan',v_promo.plan_id)); return v_workspace; end; $function$; diff --git a/ops/sql/SUPABASE-V17.9-DEVELOPER-SETTINGS.sql b/ops/sql/SUPABASE-V17.9-DEVELOPER-SETTINGS.sql new file mode 100644 index 0000000..150ca90 --- /dev/null +++ b/ops/sql/SUPABASE-V17.9-DEVELOPER-SETTINGS.sql @@ -0,0 +1,97 @@ +-- Sun Catering v17.9 developer settings. +-- Safe, additive migration for the existing v17 schema. + +begin; + +-- The platform administrator can inspect normalized health for any company. +-- Ordinary users remain limited to companies where they are active members. +create or replace function public.sun_v17_entity_snapshot(p_workspace uuid) +returns jsonb +language plpgsql +stable +security definer +set search_path='public' +as $$ +begin + if public.sun_member_role(p_workspace) is null and not public.sun_is_platform_admin() then + raise exception 'Access denied'; + end if; + return jsonb_build_object( + 'orders',coalesce(( + select jsonb_agg(jsonb_build_object('id',order_id,'version',version,'data',data,'updated_at',updated_at) order by order_id) + from public.sun_v17_orders where workspace_id=p_workspace + ),'[]'::jsonb), + 'catalog',coalesce(( + select jsonb_agg(jsonb_build_object('id',item_id,'version',version,'data',data,'updated_at',updated_at) order by item_id) + from public.sun_v17_catalog_items where workspace_id=p_workspace + ),'[]'::jsonb), + 'meta',coalesce(( + select to_jsonb(meta) from public.sun_v17_workspace_meta meta where workspace_id=p_workspace + ),'{}'::jsonb) + ); +end; +$$; + +-- The platform administrator can list backups for any company from the +-- protected developer panel. Members can still list their own company's rows. +create or replace function public.sun_v17_list_backups(p_workspace uuid, p_limit integer default 30) +returns table(id uuid, kind text, label text, created_at timestamptz, created_by uuid) +language plpgsql +stable +security definer +set search_path='public' +as $$ +begin + if public.sun_member_role(p_workspace) is null and not public.sun_is_platform_admin() then + raise exception 'Access denied'; + end if; + return query + select backup.id,backup.kind,backup.label,backup.created_at,backup.created_by + from public.sun_v17_backups backup + where backup.workspace_id=p_workspace + order by backup.created_at desc + limit greatest(1,least(coalesce(p_limit,30),100)); +end; +$$; + +-- Global technical error directory. It intentionally excludes stack/context +-- from the browser result; those fields can contain sensitive implementation data. +create or replace function public.sun_platform_list_errors(p_workspace uuid default null, p_limit integer default 80) +returns table( + id uuid, + workspace_id uuid, + workspace_name text, + user_id uuid, + client_id text, + app_version text, + level text, + message text, + created_at timestamptz +) +language plpgsql +stable +security definer +set search_path='public' +as $$ +begin + if not public.sun_is_platform_admin() then + raise exception 'Platform administrator required'; + end if; + return query + select err.id,err.workspace_id,company.name,err.user_id,err.client_id,err.app_version,err.level,err.message,err.created_at + from public.sun_v17_error_events err + left join public.sun_workspaces company on company.id=err.workspace_id + where p_workspace is null or err.workspace_id=p_workspace + order by err.created_at desc + limit greatest(1,least(coalesce(p_limit,80),200)); +end; +$$; + +revoke all on function public.sun_v17_entity_snapshot(uuid) from public, anon; +revoke all on function public.sun_v17_list_backups(uuid,integer) from public, anon; +revoke all on function public.sun_platform_list_errors(uuid,integer) from public, anon; +grant execute on function public.sun_v17_entity_snapshot(uuid) to authenticated; +grant execute on function public.sun_v17_list_backups(uuid,integer) to authenticated; +grant execute on function public.sun_platform_list_errors(uuid,integer) to authenticated; + +commit; diff --git a/ops/timeweb/api-proxy.php b/ops/timeweb/api-proxy.php index 40e29ca..6202e8d 100644 --- a/ops/timeweb/api-proxy.php +++ b/ops/timeweb/api-proxy.php @@ -6,7 +6,7 @@ * Upstream host is a fixed constant - never derived from request input (no open-proxy risk). */ -const UPSTREAM = 'https://cksuehzcimitsxmeloes.supabase.co'; +const UPSTREAM = 'https://usfjwhztqoopzzfmfbis.supabase.co'; const PUBLIC_BASE = 'https://api.caterium.ru'; const SERVE_HOST = 'api.caterium.ru'; diff --git a/package-lock.json b/package-lock.json index fe638fc..5b016bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "caterium-app", "version": "17.7.3", "devDependencies": { + "@electric-sql/pglite": "0.5.8", "@playwright/test": "^1.51.0", "http-server": "^14.1.1", "wrangler": "4.131.0" @@ -137,6 +138,13 @@ "node": ">=12" } }, + "node_modules/@electric-sql/pglite": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz", + "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@emnapi/runtime": { "version": "1.11.3", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", diff --git a/package.json b/package.json index c86e58c..fc9b551 100644 --- a/package.json +++ b/package.json @@ -7,14 +7,17 @@ "check:syntax": "node --check public/app-runtime.js && node --check public/service-worker.js && node --check public/legacy/bootstrap.js && node --check public/core/sun-safe.js && node --check public/core/account-center-v1780.js && node --check public/core/performance.js && node --check public/core/auth-security-v1774.js && node --check public/core/trial-promo-developer-v181.js && node --check public/core/order-enhancements-v1775.js && node --check public/core/data-layer-v1773.js && node --check public/core/server-automation-v1770.js && node --check public/core/hotfix-v1763.js && node --check public/core/ops-ux-v1762.js && node --check public/core/ux-fixes-v1764.js && node --check public/core/pdf-engine.js && node --check public/core/classic-offer-pdf-v1767.js && node --check public/core/signature-offer-pdf-v18.js && node --check public/core/developer-console-v1768.js && node --check public/core/offer-workspace-v1769.js", "test:static": "node tests/static-security.mjs && node tests/auth-security-v1774.mjs && node tests/employee-create-v1774.mjs && node tests/html-integrity-v1774.mjs && node tests/edge-security-v1774.mjs && node tests/branding-v1774.mjs && node tests/order-enhancements-v1775.mjs", "check:release": "node tests/release-check.mjs", - "check:deploy": "npm run check:syntax && npm run test:static && npm run check:release", + "check:deploy": "npm run check:syntax && npm run test:static && npm run check:release && node tests/backend-cutover.mjs && npm run test:db", + "test:db": "node tests/recovery/validate.mjs --smoke", + "build:recovery": "node tests/recovery/bundle.mjs", "test:e2e": "playwright test --config=tests/playwright.config.mjs", "test": "npm run check:deploy", "deploy:cloudflare-backup": "wrangler deploy" }, "devDependencies": { + "@electric-sql/pglite": "0.5.8", "@playwright/test": "^1.51.0", "http-server": "^14.1.1", "wrangler": "4.131.0" } -} \ No newline at end of file +} diff --git a/public/app-runtime.js b/public/app-runtime.js index a497832..89b92a4 100644 --- a/public/app-runtime.js +++ b/public/app-runtime.js @@ -2115,8 +2115,8 @@ window.SUN_LEGACY_CATALOG_V175=[{"id":"1","name":"Фуршетный бокс console[type === 'error' ? 'error' : 'log']('[SunCloud]', text); }; - const DEFAULT_SUPABASE_URL = 'https://cksuehzcimitsxmeloes.supabase.co'; - const DEFAULT_SUPABASE_KEY = 'sb_publishable_v8Z3hEBnu7zsDwAb5KCWcg_T96IejsM'; + const DEFAULT_SUPABASE_URL = 'https://usfjwhztqoopzzfmfbis.supabase.co'; + const DEFAULT_SUPABASE_KEY = 'sb_publishable_CAxfhMKrduJjuk_5ybCQLg_TqSGWGoy'; const SUPABASE_API_PROXY = 'https://api.caterium.ru'; const PROXY_FETCH_TIMEOUT_MS = 7000; function supabaseProxyFetch(input, init) { @@ -2180,6 +2180,18 @@ window.SUN_LEGACY_CATALOG_V175=[{"id":"1","name":"Фуршетный бокс value.key ||= String(cloud.anonKey || '').trim(); } catch (_) {} } + // Move the retired Caterium backend to the fresh project once. Preserve the + // old workspace identity so switchTenantLocal archives its data separately. + if (/^https:\/\/(?:cksuehzcimitsxmeloes\.supabase\.co|api\.caterium\.ru)\/?$/i.test(String(value.url||'')) || value.key==='sb_publishable_v8Z3hEBnu7zsDwAb5KCWcg_T96IejsM') { + try { localStorage.setItem('cateriumRetiredBackend20260917',JSON.stringify(value)); } catch (_) {} + value.legacyLocalWorkspaceId ||= value.localWorkspaceId || value.workspaceId || 'retired-caterium'; + value.url=DEFAULT_SUPABASE_URL; + value.key=DEFAULT_SUPABASE_KEY; + value.workspaceId=''; + value.migrated={}; + value.lastSync=''; + try { localStorage.setItem(CONFIG_KEY,JSON.stringify(value)); } catch (_) {} + } return { url: String(value.url || DEFAULT_SUPABASE_URL || '').trim().replace(/\/$/, ''), key: String(value.key || DEFAULT_SUPABASE_KEY || '').trim(), diff --git a/public/core/auth-security-v1774.js b/public/core/auth-security-v1774.js index 01d1391..cbf5230 100644 --- a/public/core/auth-security-v1774.js +++ b/public/core/auth-security-v1774.js @@ -3,8 +3,8 @@ const VERSION='17.8.4-auth-proxy-fallback'; const PENDING_REGISTRATION_KEY='sunPendingRegistrationV23'; - const DIRECT_SUPABASE_URL='https://cksuehzcimitsxmeloes.supabase.co'; - const DIRECT_SUPABASE_KEY='sb_publishable_v8Z3hEBnu7zsDwAb5KCWcg_T96IejsM'; + const DIRECT_SUPABASE_URL='https://usfjwhztqoopzzfmfbis.supabase.co'; + const DIRECT_SUPABASE_KEY='sb_publishable_CAxfhMKrduJjuk_5ybCQLg_TqSGWGoy'; let busy=false,directClient=null; const $=id=>document.getElementById(id); diff --git a/public/index.html b/public/index.html index 32cbc3a..84eaefd 100644 --- a/public/index.html +++ b/public/index.html @@ -95,7 +95,7 @@ button{touch-action:manipulation} #sunGlobalSearchBtn kbd{display:none!important} #sunSyncSettingsCard>div[style*="grid-template-columns"]{grid-template-columns:1fr!important} -}
Caterium

Стоимость позиций0 ₽

Предоплата0 ₽

К оплате0 ₽

Заказы

МероприятиеДата и времяАдресСуммаСтатусДействия

Нажмите «Изменить», чтобы открыть заказ. Сумма, предоплата и остаток сохраняются вместе с заказом.

Склад

В этой версии каталог продуктов пополняется из составов боксов. Остатки и цены добавим следующим шагом.

Статистика

- + diff --git a/public/service-worker.js b/public/service-worker.js index 1fc4b96..434b3a8 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,5 +1,5 @@ -const CACHE='sun-catering-pwa-v81-20260912-account-center-loader'; -const VERSION='20260914-safe-navigation'; +const CACHE='sun-catering-pwa-v82-20260917-caterium-fresh'; +const VERSION='20260917-caterium-fresh'; const CORE=[ './','./index.html', `./core/sun-safe.js?v=${VERSION}`,`./core/performance.js?v=${VERSION}`,`./core/account-center-v1780.js?v=${VERSION}`,`./core/login-signature-v1776.js?v=${VERSION}`,`./core/data-layer-v1773.js?v=${VERSION}`,`./core/server-automation-v1770.js?v=${VERSION}`,`./core/hotfix-v1763.js?v=${VERSION}`,`./core/ops-ux-v1762.js?v=${VERSION}`,`./core/ux-fixes-v1764.js?v=${VERSION}`,`./core/pdf-engine.js?v=${VERSION}`,`./core/classic-offer-pdf-v1767.js?v=${VERSION}`,`./core/developer-console-v1768.js?v=${VERSION}`,`./core/offer-workspace-v1769.js?v=${VERSION}`,`./core/auth-security-v1774.js?v=${VERSION}`,`./core/order-enhancements-v1775.js?v=${VERSION}`,`./legacy/bootstrap.js?v=${VERSION}`,`./app-runtime.js?v=${VERSION}`, diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 0000000..9a7ce18 --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,7 @@ +project_id = "caterium-fresh" + +[functions.caterium-create-employee] +verify_jwt = true + +[functions.caterium-platform-auth-admin] +verify_jwt = true diff --git a/supabase/functions/caterium-platform-auth-admin/index.ts b/supabase/functions/caterium-platform-auth-admin/index.ts index 0d80de8..b4bec12 100644 --- a/supabase/functions/caterium-platform-auth-admin/index.ts +++ b/supabase/functions/caterium-platform-auth-admin/index.ts @@ -42,6 +42,8 @@ Deno.serve(async (req) => { if (userError || !user) return json(req, {error:'Unauthorized'},401) const {data:isAdmin,error:adminError} = await userClient.rpc('sun_is_platform_admin') if (adminError || isAdmin !== true) return json(req, {error:'Platform administrator required'},403) + const {error:mfaError} = await userClient.rpc('sun_dev_dashboard') + if (mfaError) return json(req, {error:'Developer MFA AAL2 required'},403) const body = await req.json().catch(()=>({})) as Record const action = String(body.action || 'list') diff --git a/supabase/migrations/20260917150000_fresh_caterium.sql b/supabase/migrations/20260917150000_fresh_caterium.sql new file mode 100644 index 0000000..34967fe --- /dev/null +++ b/supabase/migrations/20260917150000_fresh_caterium.sql @@ -0,0 +1,4519 @@ +-- Caterium fresh recovery; target usfjwhztqoopzzfmfbis only. +-- Do not run against an existing database. +begin; +set local lock_timeout='10s'; +set local statement_timeout='120s'; +do $$begin if to_regclass('public.sun_workspaces') is not null then raise exception 'Fresh recovery requires an empty Caterium schema'; end if; end $$; +create extension if not exists pg_cron; + +-- Source: ops/sql/SUPABASE-SETUP.sql +-- Sun Catering Cloud v2 +-- Run this entire file once in Supabase -> SQL Editor. +-- Safe to re-run: objects are created with IF NOT EXISTS where possible. + +create extension if not exists pgcrypto; + +create table if not exists public.sun_workspaces ( + id uuid primary key default gen_random_uuid(), + name text not null check (char_length(trim(name)) between 1 and 120), + created_by uuid not null references auth.users(id) on delete cascade, + created_at timestamptz not null default now() +); + +create table if not exists public.sun_workspace_members ( + workspace_id uuid not null references public.sun_workspaces(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + role text not null default 'manager' check (role in ('owner','manager','viewer')), + created_at timestamptz not null default now(), + primary key (workspace_id, user_id) +); + +create table if not exists public.sun_app_state ( + workspace_id uuid primary key references public.sun_workspaces(id) on delete cascade, + payload jsonb not null default '{"format":"sun-cloud-v2","version":2,"storage":{}}'::jsonb, + revision bigint not null default 0, + client_id text, + updated_by uuid references auth.users(id) on delete set null, + updated_at timestamptz not null default now() +); + +create table if not exists public.sun_workspace_invites ( + token uuid primary key default gen_random_uuid(), + workspace_id uuid not null references public.sun_workspaces(id) on delete cascade, + role text not null default 'manager' check (role in ('manager','viewer')), + created_by uuid not null references auth.users(id) on delete cascade, + created_at timestamptz not null default now(), + expires_at timestamptz not null default (now() + interval '7 days'), + used_by uuid references auth.users(id) on delete set null, + used_at timestamptz +); + +create index if not exists sun_workspace_members_user_idx on public.sun_workspace_members(user_id); +create index if not exists sun_workspace_invites_workspace_idx on public.sun_workspace_invites(workspace_id); + +create or replace function public.sun_member_role(p_workspace uuid) +returns text +language sql +stable +security definer +set search_path = public +as $$ + select m.role + from public.sun_workspace_members m + where m.workspace_id = p_workspace + and m.user_id = auth.uid() + limit 1; +$$; + +revoke all on function public.sun_member_role(uuid) from public; +grant execute on function public.sun_member_role(uuid) to authenticated; + +create or replace function public.sun_touch_app_state() +returns trigger +language plpgsql +security definer +set search_path = public +as $$ +begin + new.updated_at := now(); + if tg_op = 'UPDATE' then + new.revision := old.revision + 1; + end if; + new.updated_by := auth.uid(); + return new; +end; +$$; + +drop trigger if exists sun_app_state_touch on public.sun_app_state; +create trigger sun_app_state_touch +before insert or update on public.sun_app_state +for each row execute function public.sun_touch_app_state(); + +revoke all on function public.sun_touch_app_state() from public; +revoke all on function public.sun_touch_app_state() from anon; +revoke all on function public.sun_touch_app_state() from authenticated; + +create or replace function public.sun_create_workspace(p_name text default 'Солнце Кейтеринг') +returns uuid +language plpgsql +security definer +set search_path = public +as $$ +declare + v_user uuid := auth.uid(); + v_workspace uuid; +begin + if v_user is null then + raise exception 'Authentication required'; + end if; + + insert into public.sun_workspaces(name, created_by) + values (coalesce(nullif(trim(p_name),''),'Солнце Кейтеринг'), v_user) + returning id into v_workspace; + + insert into public.sun_workspace_members(workspace_id, user_id, role) + values (v_workspace, v_user, 'owner'); + + insert into public.sun_app_state(workspace_id, payload, client_id) + values (v_workspace, '{"format":"sun-cloud-v2","version":2,"storage":{}}'::jsonb, 'bootstrap') + on conflict (workspace_id) do nothing; + + return v_workspace; +end; +$$; + +revoke all on function public.sun_create_workspace(text) from public; +grant execute on function public.sun_create_workspace(text) to authenticated; + +create or replace function public.sun_create_invite(p_workspace uuid, p_role text default 'manager') +returns uuid +language plpgsql +security definer +set search_path = public +as $$ +declare + v_token uuid; + v_role text := lower(coalesce(p_role,'manager')); +begin + if public.sun_member_role(p_workspace) <> 'owner' then + raise exception 'Owner role required'; + end if; + if v_role not in ('manager','viewer') then + raise exception 'Invalid role'; + end if; + + insert into public.sun_workspace_invites(workspace_id, role, created_by) + values (p_workspace, v_role, auth.uid()) + returning token into v_token; + + return v_token; +end; +$$; + +revoke all on function public.sun_create_invite(uuid,text) from public; +grant execute on function public.sun_create_invite(uuid,text) to authenticated; + +create or replace function public.sun_accept_invite(p_token uuid) +returns uuid +language plpgsql +security definer +set search_path = public +as $$ +declare + v_user uuid := auth.uid(); + v_invite public.sun_workspace_invites%rowtype; +begin + if v_user is null then + raise exception 'Authentication required'; + end if; + + select * into v_invite + from public.sun_workspace_invites + where token = p_token + for update; + + if not found then raise exception 'Invite not found'; end if; + if v_invite.used_at is not null then raise exception 'Invite already used'; end if; + if v_invite.expires_at < now() then raise exception 'Invite expired'; end if; + + insert into public.sun_workspace_members(workspace_id, user_id, role) + values (v_invite.workspace_id, v_user, v_invite.role) + on conflict (workspace_id, user_id) + do update set role = excluded.role; + + update public.sun_workspace_invites + set used_by = v_user, used_at = now() + where token = p_token; + + return v_invite.workspace_id; +end; +$$; + +revoke all on function public.sun_accept_invite(uuid) from public; +grant execute on function public.sun_accept_invite(uuid) to authenticated; + +alter table public.sun_workspaces enable row level security; +alter table public.sun_workspace_members enable row level security; +alter table public.sun_app_state enable row level security; +alter table public.sun_workspace_invites enable row level security; + +-- Remove overly broad browser grants, then grant only what authenticated clients need. +revoke all on public.sun_workspaces from anon; +revoke all on public.sun_workspace_members from anon; +revoke all on public.sun_app_state from anon; +revoke all on public.sun_workspace_invites from anon; + +grant select on public.sun_workspaces to authenticated; +grant select on public.sun_workspace_members to authenticated; +grant select, insert, update on public.sun_app_state to authenticated; +grant select on public.sun_workspace_invites to authenticated; + +-- Recreate policies idempotently. +drop policy if exists sun_workspaces_read on public.sun_workspaces; +create policy sun_workspaces_read on public.sun_workspaces +for select to authenticated +using (public.sun_member_role(id) is not null); + +drop policy if exists sun_members_read on public.sun_workspace_members; +create policy sun_members_read on public.sun_workspace_members +for select to authenticated +using (user_id = auth.uid() or public.sun_member_role(workspace_id) = 'owner'); + +drop policy if exists sun_state_read on public.sun_app_state; +create policy sun_state_read on public.sun_app_state +for select to authenticated +using (public.sun_member_role(workspace_id) is not null); + +drop policy if exists sun_state_insert on public.sun_app_state; +create policy sun_state_insert on public.sun_app_state +for insert to authenticated +with check (public.sun_member_role(workspace_id) in ('owner','manager')); + +drop policy if exists sun_state_update on public.sun_app_state; +create policy sun_state_update on public.sun_app_state +for update to authenticated +using (public.sun_member_role(workspace_id) in ('owner','manager')) +with check (public.sun_member_role(workspace_id) in ('owner','manager')); + +drop policy if exists sun_invites_read on public.sun_workspace_invites; +create policy sun_invites_read on public.sun_workspace_invites +for select to authenticated +using (public.sun_member_role(workspace_id) = 'owner'); + +-- Private media bucket. The first folder is always the workspace UUID. +insert into storage.buckets(id, name, public, file_size_limit, allowed_mime_types) +values ( + 'sun-media', + 'sun-media', + false, + 15728640, + array['image/jpeg','image/png','image/webp','image/gif'] +) +on conflict (id) do update set + public = excluded.public, + file_size_limit = excluded.file_size_limit, + allowed_mime_types = excluded.allowed_mime_types; + +-- Storage policies. +drop policy if exists sun_media_read on storage.objects; +create policy sun_media_read on storage.objects +for select to authenticated +using ( + bucket_id = 'sun-media' + and public.sun_member_role(((storage.foldername(name))[1])::uuid) is not null +); + +drop policy if exists sun_media_insert on storage.objects; +create policy sun_media_insert on storage.objects +for insert to authenticated +with check ( + bucket_id = 'sun-media' + and public.sun_member_role(((storage.foldername(name))[1])::uuid) in ('owner','manager') +); + +drop policy if exists sun_media_update on storage.objects; +create policy sun_media_update on storage.objects +for update to authenticated +using ( + bucket_id = 'sun-media' + and public.sun_member_role(((storage.foldername(name))[1])::uuid) in ('owner','manager') +) +with check ( + bucket_id = 'sun-media' + and public.sun_member_role(((storage.foldername(name))[1])::uuid) in ('owner','manager') +); + +drop policy if exists sun_media_delete on storage.objects; +create policy sun_media_delete on storage.objects +for delete to authenticated +using ( + bucket_id = 'sun-media' + and public.sun_member_role(((storage.foldername(name))[1])::uuid) in ('owner','manager') +); + +-- Realtime publication for the single workspace-state row. +do $$ +begin + if not exists ( + select 1 from pg_publication_tables + where pubname = 'supabase_realtime' + and schemaname = 'public' + and tablename = 'sun_app_state' + ) then + alter publication supabase_realtime add table public.sun_app_state; + end if; +end $$; + + +-- Source: ops/sql/SUPABASE-RBAC-V3.sql +-- Sun Catering Cloud RBAC v3 +-- Roles, granular permissions, administrator tools, secured state RPCs and realtime sync events. + +alter table public.sun_workspace_members add column if not exists display_name text; +alter table public.sun_workspace_members add column if not exists is_active boolean not null default true; +alter table public.sun_workspace_members add column if not exists permissions jsonb not null default '{}'::jsonb; +alter table public.sun_workspace_members add column if not exists updated_at timestamptz not null default now(); + +alter table public.sun_workspace_invites add column if not exists permissions jsonb; + +alter table public.sun_workspace_members drop constraint if exists sun_workspace_members_role_check; +update public.sun_workspace_members set role='admin' where role='owner'; +alter table public.sun_workspace_members add constraint sun_workspace_members_role_check + check (role in ('admin','manager','kitchen','courier','viewer')); + +alter table public.sun_workspace_invites drop constraint if exists sun_workspace_invites_role_check; +alter table public.sun_workspace_invites add constraint sun_workspace_invites_role_check + check (role in ('admin','manager','kitchen','courier','viewer')); + +create or replace function public.sun_role_default_permissions(p_role text) +returns jsonb +language sql +immutable +set search_path = public +as $$ + select case lower(coalesce(p_role,'')) + when 'admin' then jsonb_build_object( + 'app.read',true, + 'orders.view',true,'orders.create',true,'orders.edit',true,'orders.delete',true, + 'clients.view',true,'clients.edit',true, + 'catalog.view',true,'catalog.edit',true, + 'calendar.view',true,'map.view',true, + 'production.view',true,'production.edit',true, + 'shopping.view',true,'shopping.edit',true, + 'stock.view',true,'stock.edit',true, + 'routes.view',true,'routes.edit',true, + 'mailings.view',true,'mailings.edit',true, + 'money.view',true,'money.edit',true, + 'stats.view',true, + 'team.view',true,'team.edit',true, + 'suppliers.view',true,'suppliers.edit',true, + 'print.view',true, + 'settings.view',true,'settings.edit',true, + 'users.manage',true,'audit.view',true,'backups.manage',true + ) + when 'manager' then jsonb_build_object( + 'app.read',true, + 'orders.view',true,'orders.create',true,'orders.edit',true,'orders.delete',false, + 'clients.view',true,'clients.edit',true, + 'catalog.view',true,'catalog.edit',false, + 'calendar.view',true,'map.view',true, + 'production.view',false,'production.edit',false, + 'shopping.view',false,'shopping.edit',false, + 'stock.view',false,'stock.edit',false, + 'routes.view',true,'routes.edit',false, + 'mailings.view',true,'mailings.edit',true, + 'money.view',false,'money.edit',false, + 'stats.view',true, + 'team.view',true,'team.edit',false, + 'suppliers.view',false,'suppliers.edit',false, + 'print.view',true, + 'settings.view',false,'settings.edit',false, + 'users.manage',false,'audit.view',false,'backups.manage',false + ) + when 'kitchen' then jsonb_build_object( + 'app.read',true, + 'orders.view',true,'orders.create',false,'orders.edit',false,'orders.delete',false, + 'clients.view',false,'clients.edit',false, + 'catalog.view',true,'catalog.edit',false, + 'calendar.view',true,'map.view',false, + 'production.view',true,'production.edit',true, + 'shopping.view',true,'shopping.edit',false, + 'stock.view',true,'stock.edit',true, + 'routes.view',false,'routes.edit',false, + 'mailings.view',false,'mailings.edit',false, + 'money.view',false,'money.edit',false, + 'stats.view',false, + 'team.view',true,'team.edit',false, + 'suppliers.view',false,'suppliers.edit',false, + 'print.view',true, + 'settings.view',false,'settings.edit',false, + 'users.manage',false,'audit.view',false,'backups.manage',false + ) + when 'courier' then jsonb_build_object( + 'app.read',true, + 'orders.view',true,'orders.create',false,'orders.edit',false,'orders.delete',false, + 'clients.view',false,'clients.edit',false, + 'catalog.view',false,'catalog.edit',false, + 'calendar.view',true,'map.view',true, + 'production.view',false,'production.edit',false, + 'shopping.view',false,'shopping.edit',false, + 'stock.view',false,'stock.edit',false, + 'routes.view',true,'routes.edit',true, + 'mailings.view',false,'mailings.edit',false, + 'money.view',false,'money.edit',false, + 'stats.view',false, + 'team.view',false,'team.edit',false, + 'suppliers.view',false,'suppliers.edit',false, + 'print.view',false, + 'settings.view',false,'settings.edit',false, + 'users.manage',false,'audit.view',false,'backups.manage',false + ) + else jsonb_build_object( + 'app.read',true, + 'orders.view',true,'orders.create',false,'orders.edit',false,'orders.delete',false, + 'clients.view',true,'clients.edit',false, + 'catalog.view',true,'catalog.edit',false, + 'calendar.view',true,'map.view',true, + 'production.view',false,'production.edit',false, + 'shopping.view',false,'shopping.edit',false, + 'stock.view',false,'stock.edit',false, + 'routes.view',false,'routes.edit',false, + 'mailings.view',false,'mailings.edit',false, + 'money.view',false,'money.edit',false, + 'stats.view',true, + 'team.view',false,'team.edit',false, + 'suppliers.view',false,'suppliers.edit',false, + 'print.view',true, + 'settings.view',false,'settings.edit',false, + 'users.manage',false,'audit.view',false,'backups.manage',false + ) + end; +$$; + +create or replace function public.sun_member_role(p_workspace uuid) +returns text +language sql +stable +security definer +set search_path = public +as $$ + select m.role + from public.sun_workspace_members m + where m.workspace_id = p_workspace + and m.user_id = auth.uid() + and m.is_active = true + limit 1; +$$; + +create or replace function public.sun_has_permission(p_workspace uuid, p_permission text) +returns boolean +language plpgsql +stable +security definer +set search_path = public +as $$ +declare + v_role text; + v_permissions jsonb; + v_active boolean; +begin + select role, permissions, is_active + into v_role, v_permissions, v_active + from public.sun_workspace_members + where workspace_id=p_workspace and user_id=auth.uid() + limit 1; + + if not coalesce(v_active,false) then return false; end if; + if v_role='admin' then return true; end if; + if v_permissions ? p_permission then + return coalesce((v_permissions->>p_permission)::boolean,false); + end if; + return coalesce((public.sun_role_default_permissions(v_role)->>p_permission)::boolean,false); +end; +$$; + +revoke all on function public.sun_member_role(uuid) from public, anon; +grant execute on function public.sun_member_role(uuid) to authenticated; +revoke all on function public.sun_has_permission(uuid,text) from public, anon; +grant execute on function public.sun_has_permission(uuid,text) to authenticated; +revoke all on function public.sun_role_default_permissions(text) from public, anon; +grant execute on function public.sun_role_default_permissions(text) to authenticated; + +create or replace function public.sun_create_workspace(p_name text default 'Солнце Кейтеринг') +returns uuid +language plpgsql +security definer +set search_path = public +as $$ +declare + v_user uuid := auth.uid(); + v_workspace uuid; + v_name text; +begin + if v_user is null then raise exception 'Authentication required'; end if; + v_name := coalesce(nullif(trim(p_name),''),'Солнце Кейтеринг'); + insert into public.sun_workspaces(name, created_by) + values (v_name, v_user) + returning id into v_workspace; + insert into public.sun_workspace_members(workspace_id,user_id,role,display_name,is_active,permissions) + values (v_workspace,v_user,'admin',coalesce((select raw_user_meta_data->>'name' from auth.users where id=v_user),split_part(coalesce((select email from auth.users where id=v_user),'Администратор'),'@',1)),true,public.sun_role_default_permissions('admin')); + insert into public.sun_app_state(workspace_id,payload,client_id) + values (v_workspace,'{"format":"sun-cloud-v2","version":2,"storage":{}}'::jsonb,'bootstrap') + on conflict (workspace_id) do nothing; + return v_workspace; +end; +$$; +revoke all on function public.sun_create_workspace(text) from public, anon; +grant execute on function public.sun_create_workspace(text) to authenticated; + +create or replace function public.sun_create_invite(p_workspace uuid, p_role text default 'manager') +returns uuid +language plpgsql +security definer +set search_path = public +as $$ +declare + v_token uuid; + v_role text := lower(coalesce(p_role,'manager')); +begin + if not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Administrator permission required'; end if; + if v_role not in ('admin','manager','kitchen','courier','viewer') then raise exception 'Invalid role'; end if; + insert into public.sun_workspace_invites(workspace_id,role,permissions,created_by) + values (p_workspace,v_role,public.sun_role_default_permissions(v_role),auth.uid()) + returning token into v_token; + return v_token; +end; +$$; +revoke all on function public.sun_create_invite(uuid,text) from public, anon; +grant execute on function public.sun_create_invite(uuid,text) to authenticated; + +create or replace function public.sun_accept_invite(p_token uuid) +returns uuid +language plpgsql +security definer +set search_path = public +as $$ +declare + v_user uuid := auth.uid(); + v_invite public.sun_workspace_invites%rowtype; + v_display text; +begin + if v_user is null then raise exception 'Authentication required'; end if; + select * into v_invite from public.sun_workspace_invites where token=p_token for update; + if not found then raise exception 'Invite not found'; end if; + if v_invite.used_at is not null then raise exception 'Invite already used'; end if; + if v_invite.expires_at < now() then raise exception 'Invite expired'; end if; + select coalesce(nullif(raw_user_meta_data->>'name',''),split_part(coalesce(email,'Сотрудник'),'@',1)) into v_display from auth.users where id=v_user; + insert into public.sun_workspace_members(workspace_id,user_id,role,display_name,is_active,permissions,updated_at) + values (v_invite.workspace_id,v_user,v_invite.role,v_display,true,coalesce(v_invite.permissions,public.sun_role_default_permissions(v_invite.role)),now()) + on conflict (workspace_id,user_id) do update set role=excluded.role,display_name=coalesce(public.sun_workspace_members.display_name,excluded.display_name),is_active=true,permissions=excluded.permissions,updated_at=now(); + update public.sun_workspace_invites set used_by=v_user,used_at=now() where token=p_token; + return v_invite.workspace_id; +end; +$$; +revoke all on function public.sun_accept_invite(uuid) from public, anon; +grant execute on function public.sun_accept_invite(uuid) to authenticated; + +create or replace function public.sun_list_workspace_members(p_workspace uuid) +returns table(user_id uuid,email text,display_name text,role text,is_active boolean,permissions jsonb,created_at timestamptz,updated_at timestamptz) +language plpgsql +stable +security definer +set search_path = public +as $$ +begin + if not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Administrator permission required'; end if; + return query + select m.user_id,u.email,m.display_name,m.role,m.is_active, + coalesce(m.permissions,public.sun_role_default_permissions(m.role)),m.created_at,m.updated_at + from public.sun_workspace_members m + join auth.users u on u.id=m.user_id + where m.workspace_id=p_workspace + order by (m.role='admin') desc,coalesce(m.display_name,u.email); +end; +$$; +revoke all on function public.sun_list_workspace_members(uuid) from public, anon; +grant execute on function public.sun_list_workspace_members(uuid) to authenticated; + +create or replace function public.sun_admin_update_member( + p_workspace uuid, + p_user uuid, + p_display_name text, + p_role text, + p_is_active boolean, + p_permissions jsonb +) +returns void +language plpgsql +security definer +set search_path = public +as $$ +declare + v_old_role text; + v_old_active boolean; + v_admins int; + v_role text := lower(coalesce(p_role,'')); +begin + if not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Administrator permission required'; end if; + if v_role not in ('admin','manager','kitchen','courier','viewer') then raise exception 'Invalid role'; end if; + if p_permissions is null or jsonb_typeof(p_permissions) <> 'object' then raise exception 'Permissions must be an object'; end if; + + select role,is_active into v_old_role,v_old_active from public.sun_workspace_members where workspace_id=p_workspace and user_id=p_user for update; + if not found then raise exception 'Member not found'; end if; + + if v_old_role='admin' and coalesce(v_old_active,false) and (v_role<>'admin' or not coalesce(p_is_active,false)) then + select count(*) into v_admins from public.sun_workspace_members where workspace_id=p_workspace and role='admin' and is_active=true; + if v_admins <= 1 then raise exception 'Нельзя отключить или понизить последнего администратора'; end if; + end if; + + update public.sun_workspace_members + set display_name=nullif(trim(coalesce(p_display_name,'')),''),role=v_role,is_active=coalesce(p_is_active,false),permissions=p_permissions,updated_at=now() + where workspace_id=p_workspace and user_id=p_user; +end; +$$; +revoke all on function public.sun_admin_update_member(uuid,uuid,text,text,boolean,jsonb) from public, anon; +grant execute on function public.sun_admin_update_member(uuid,uuid,text,text,boolean,jsonb) to authenticated; + +create or replace function public.sun_admin_remove_member(p_workspace uuid,p_user uuid) +returns void +language plpgsql +security definer +set search_path = public +as $$ +declare + v_role text; + v_active boolean; + v_admins int; +begin + if not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Administrator permission required'; end if; + select role,is_active into v_role,v_active from public.sun_workspace_members where workspace_id=p_workspace and user_id=p_user for update; + if not found then return; end if; + if v_role='admin' and coalesce(v_active,false) then + select count(*) into v_admins from public.sun_workspace_members where workspace_id=p_workspace and role='admin' and is_active=true; + if v_admins <= 1 then raise exception 'Нельзя удалить последнего администратора'; end if; + end if; + delete from public.sun_workspace_members where workspace_id=p_workspace and user_id=p_user; +end; +$$; +revoke all on function public.sun_admin_remove_member(uuid,uuid) from public, anon; +grant execute on function public.sun_admin_remove_member(uuid,uuid) to authenticated; + +create or replace function public.sun_can_read_storage_key(p_workspace uuid,p_key text) +returns boolean +language plpgsql +stable +security definer +set search_path = public +as $$ +begin + if public.sun_member_role(p_workspace) is null then return false; end if; + return case + when p_key='sunOrders' then public.sun_has_permission(p_workspace,'orders.view') or public.sun_has_permission(p_workspace,'orders.create') or public.sun_has_permission(p_workspace,'orders.edit') + when p_key='sunBoxes' then public.sun_has_permission(p_workspace,'catalog.view') or public.sun_has_permission(p_workspace,'orders.create') or public.sun_has_permission(p_workspace,'production.view') or public.sun_has_permission(p_workspace,'stock.view') + when p_key in ('sunClientLoyaltyV1','sunClientCommunicationV1') then public.sun_has_permission(p_workspace,'clients.view') or public.sun_has_permission(p_workspace,'clients.edit') or public.sun_has_permission(p_workspace,'orders.create') or public.sun_has_permission(p_workspace,'orders.edit') + when p_key='sunFinanceRecordsV2' then public.sun_has_permission(p_workspace,'money.view') or public.sun_has_permission(p_workspace,'money.edit') + when p_key in ('sunStock','sunStockMoves') then public.sun_has_permission(p_workspace,'stock.view') or public.sun_has_permission(p_workspace,'stock.edit') or public.sun_has_permission(p_workspace,'shopping.view') or public.sun_has_permission(p_workspace,'production.view') + when p_key='sunEmployees' then public.sun_has_permission(p_workspace,'team.view') or public.sun_has_permission(p_workspace,'team.edit') or public.sun_has_permission(p_workspace,'production.view') or public.sun_has_permission(p_workspace,'routes.view') + when p_key='sunSuppliers' then public.sun_has_permission(p_workspace,'suppliers.view') or public.sun_has_permission(p_workspace,'suppliers.edit') or public.sun_has_permission(p_workspace,'shopping.view') + when p_key like 'sunRoute%' then public.sun_has_permission(p_workspace,'routes.view') or public.sun_has_permission(p_workspace,'routes.edit') or public.sun_has_permission(p_workspace,'map.view') + when p_key like 'sunMarketing%' then public.sun_has_permission(p_workspace,'mailings.view') or public.sun_has_permission(p_workspace,'mailings.edit') + when p_key='sunAuditLogV1' then public.sun_has_permission(p_workspace,'audit.view') + else public.sun_has_permission(p_workspace,'app.read') + end; +end; +$$; +revoke all on function public.sun_can_read_storage_key(uuid,text) from public, anon, authenticated; + +create or replace function public.sun_required_write_permission(p_key text) +returns text +language sql +immutable +as $$ + select case + when p_key='sunBoxes' then 'catalog.edit' + when p_key in ('sunClientLoyaltyV1','sunClientCommunicationV1') then 'clients.edit' + when p_key='sunFinanceRecordsV2' then 'money.edit' + when p_key in ('sunStock','sunStockMoves') then 'stock.edit' + when p_key='sunEmployees' then 'team.edit' + when p_key='sunSuppliers' then 'suppliers.edit' + when p_key like 'sunRoute%' then 'routes.edit' + when p_key like 'sunMarketing%' then 'mailings.edit' + when p_key in ('sunPromoCodesV1') then 'mailings.edit' + when p_key in ('sunCatalogCategoriesV2','sunDefaultOrderQrV1','sunLeadSourcesV1','sunEventTypesV1','sunOrderPaymentColorsV1','sunReceiptSettingsV2','sunPrintSettingsV1','sunYandexMapsSettings','sunEnterpriseSettingsV1','sunOrderStatusFeatureEnabledV1') then 'settings.edit' + when p_key='sunOfficialCatalogVersion' then 'catalog.edit' + when p_key='sunAuditLogV1' then '_member' + else 'settings.edit' + end; +$$; +revoke all on function public.sun_required_write_permission(text) from public, anon, authenticated; + +create or replace function public.sun_fetch_app_state(p_workspace uuid) +returns table(workspace_id uuid,payload jsonb,revision bigint,updated_at timestamptz,client_id text) +language plpgsql +stable +security definer +set search_path = public +as $$ +declare + v_row public.sun_app_state%rowtype; + v_storage jsonb := '{}'::jsonb; + kv record; +begin + if public.sun_member_role(p_workspace) is null then raise exception 'Access denied'; end if; + select * into v_row from public.sun_app_state where sun_app_state.workspace_id=p_workspace; + if not found then return; end if; + for kv in select key,value from jsonb_each(coalesce(v_row.payload->'storage','{}'::jsonb)) loop + if public.sun_can_read_storage_key(p_workspace,kv.key) then + v_storage := v_storage || jsonb_build_object(kv.key,kv.value); + end if; + end loop; + workspace_id := v_row.workspace_id; + payload := jsonb_build_object('format',coalesce(v_row.payload->'format','"sun-cloud-v2"'::jsonb),'version',coalesce(v_row.payload->'version','2'::jsonb),'storage',v_storage); + revision := v_row.revision; + updated_at := v_row.updated_at; + client_id := v_row.client_id; + return next; +end; +$$; +revoke all on function public.sun_fetch_app_state(uuid) from public, anon; +grant execute on function public.sun_fetch_app_state(uuid) 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_key text; + v_perm text; + v_old_orders jsonb; + v_new_orders jsonb; + v_create boolean := false; + v_delete boolean := false; + v_edit boolean := false; + v_saved public.sun_app_state%rowtype; +begin + if public.sun_member_role(p_workspace) is null then raise exception 'Access denied'; 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); + + 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 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 + 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; + else + 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; + end loop; + + insert into public.sun_app_state(workspace_id,payload,client_id) + values (p_workspace,p_payload,p_client_id) + on conflict (workspace_id) do update set payload=excluded.payload,client_id=excluded.client_id + returning * 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; +$$; + +create table if not exists public.sun_sync_events ( + id bigint generated by default as identity primary key, + workspace_id uuid not null references public.sun_workspaces(id) on delete cascade, + revision bigint not null, + client_id text, + created_at timestamptz not null default now() +); +create index if not exists sun_sync_events_workspace_idx on public.sun_sync_events(workspace_id,id desc); +alter table public.sun_sync_events enable row level security; +revoke all on public.sun_sync_events from anon; +grant select on public.sun_sync_events to authenticated; +drop policy if exists sun_sync_events_read on public.sun_sync_events; +create policy sun_sync_events_read on public.sun_sync_events for select to authenticated using (public.sun_member_role(workspace_id) is not null); + +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; + +-- The app state itself is accessible only through permission-aware RPCs. +revoke select,insert,update,delete on public.sun_app_state from authenticated, anon; +drop policy if exists sun_state_read on public.sun_app_state; +drop policy if exists sun_state_insert on public.sun_app_state; +drop policy if exists sun_state_update on public.sun_app_state; + +-- Members can read their own membership; administrators can read their workspace members. +drop policy if exists sun_members_read on public.sun_workspace_members; +create policy sun_members_read on public.sun_workspace_members for select to authenticated +using (user_id=auth.uid() or public.sun_has_permission(workspace_id,'users.manage')); + +-- Workspaces are visible only to active members. +drop policy if exists sun_workspaces_read on public.sun_workspaces; +create policy sun_workspaces_read on public.sun_workspaces for select to authenticated +using (public.sun_member_role(id) is not null); + +-- Invitations visible only to administrators. +drop policy if exists sun_invites_read on public.sun_workspace_invites; +create policy sun_invites_read on public.sun_workspace_invites for select to authenticated +using (public.sun_has_permission(workspace_id,'users.manage')); + +-- Storage: active members may read, catalogue editors may upload/change media. +drop policy if exists sun_media_read on storage.objects; +create policy sun_media_read on storage.objects for select to authenticated +using (bucket_id='sun-media' and public.sun_member_role(((storage.foldername(name))[1])::uuid) is not null); +drop policy if exists sun_media_insert on storage.objects; +create policy sun_media_insert on storage.objects for insert to authenticated +with check (bucket_id='sun-media' and public.sun_has_permission(((storage.foldername(name))[1])::uuid,'catalog.edit')); +drop policy if exists sun_media_update on storage.objects; +create policy sun_media_update on storage.objects for update to authenticated +using (bucket_id='sun-media' and public.sun_has_permission(((storage.foldername(name))[1])::uuid,'catalog.edit')) +with check (bucket_id='sun-media' and public.sun_has_permission(((storage.foldername(name))[1])::uuid,'catalog.edit')); +drop policy if exists sun_media_delete on storage.objects; +create policy sun_media_delete on storage.objects for delete to authenticated +using (bucket_id='sun-media' and public.sun_has_permission(((storage.foldername(name))[1])::uuid,'catalog.edit')); + +-- Realtime events, not the full payload, are published. +do $$ +begin + if exists(select 1 from pg_publication_tables where pubname='supabase_realtime' and schemaname='public' and tablename='sun_app_state') then + alter publication supabase_realtime drop table public.sun_app_state; + end if; + if not exists(select 1 from pg_publication_tables where pubname='supabase_realtime' and schemaname='public' and tablename='sun_sync_events') then + alter publication supabase_realtime add table public.sun_sync_events; + end if; +end $$; + +-- Bootstrap the approved administrator account if it has no workspace yet. +do $$ +declare + v_user uuid; + v_workspace uuid; + v_display text; +begin + select id,coalesce(nullif(raw_user_meta_data->>'name',''),split_part(email,'@',1)) into v_user,v_display + from auth.users where lower(email)=lower('dpavlov346@bk.ru') limit 1; + if v_user is not null then + update public.sun_workspace_members + set role='admin',is_active=true,permissions=public.sun_role_default_permissions('admin'),updated_at=now() + where user_id=v_user; + if not exists(select 1 from public.sun_workspace_members where user_id=v_user) then + insert into public.sun_workspaces(name,created_by) values ('Солнце Кейтеринг',v_user) returning id into v_workspace; + insert into public.sun_workspace_members(workspace_id,user_id,role,display_name,is_active,permissions) + values (v_workspace,v_user,'admin',v_display,true,public.sun_role_default_permissions('admin')); + insert into public.sun_app_state(workspace_id,payload,client_id) + values (v_workspace,'{"format":"sun-cloud-v2","version":2,"storage":{}}'::jsonb,'bootstrap') + on conflict (workspace_id) do nothing; + end if; + end if; +end $$; + +-- Normalize existing members' empty permissions to their role template. +update public.sun_workspace_members +set permissions=public.sun_role_default_permissions(role),updated_at=now() +where permissions='{}'::jsonb or permissions is null; + +-- FINAL RUNTIME FIXES (keep at end of file) +create or replace function public.sun_list_workspace_members(p_workspace uuid) +returns table(user_id uuid,email text,display_name text,role text,is_active boolean,permissions jsonb,created_at timestamptz,updated_at timestamptz) +language plpgsql +stable +security definer +set search_path = public +as $$ +begin + if not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Administrator permission required'; end if; + return query + select m.user_id,u.email::text,m.display_name,m.role,m.is_active, + coalesce(m.permissions,public.sun_role_default_permissions(m.role)),m.created_at,m.updated_at + from public.sun_workspace_members m + join auth.users u on u.id=m.user_id + where m.workspace_id=p_workspace + order by (m.role='admin') desc,coalesce(m.display_name,u.email::text); +end; +$$; +revoke all on function public.sun_list_workspace_members(uuid) from public, anon; +grant execute on function public.sun_list_workspace_members(uuid) to authenticated; + +create or replace function public.sun_required_write_permission(p_key text) +returns text +language sql +immutable +set search_path = public +as $$ + select case + when p_key='sunBoxes' then 'catalog.edit' + when p_key in ('sunClientLoyaltyV1','sunClientCommunicationV1') then 'clients.edit' + when p_key='sunFinanceRecordsV2' then 'money.edit' + when p_key in ('sunStock','sunStockMoves') then 'stock.edit' + when p_key='sunEmployees' then 'team.edit' + when p_key='sunSuppliers' then 'suppliers.edit' + when p_key like 'sunRoute%' then 'routes.edit' + when p_key like 'sunMarketing%' then 'mailings.edit' + when p_key in ('sunPromoCodesV1') then 'mailings.edit' + when p_key in ('sunCatalogCategoriesV2','sunDefaultOrderQrV1','sunLeadSourcesV1','sunEventTypesV1','sunOrderPaymentColorsV1','sunReceiptSettingsV2','sunPrintSettingsV1','sunYandexMapsSettings','sunEnterpriseSettingsV1','sunOrderStatusFeatureEnabledV1') then 'settings.edit' + when p_key='sunOfficialCatalogVersion' then 'catalog.edit' + when p_key='sunAuditLogV1' then '_member' + else 'settings.edit' + end; +$$; + +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_old_orders jsonb; + v_new_orders jsonb; + v_create boolean; + v_delete boolean; + v_edit boolean; + v_saved public.sun_app_state%rowtype; +begin + if public.sun_member_role(p_workspace) is null then raise exception 'Access denied'; 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 + 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; + else + 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; + + +-- Source: ops/sql/SUPABASE-SAAS-V16.sql +-- Sun Catering SaaS foundation v16 +-- Additive subscription/entitlement layer. Existing workspace remains active on Full. + +create table if not exists public.sun_plans ( + id text primary key, + name text not null, + description text not null default '', + max_members integer null check (max_members is null or max_members >= 1), + sort_order integer not null default 0, + is_active boolean not null default true, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists public.sun_plan_features ( + plan_id text not null references public.sun_plans(id) on delete cascade, + feature_key text not null, + enabled boolean not null default false, + primary key (plan_id, feature_key) +); + +create table if not exists public.sun_workspace_subscriptions ( + workspace_id uuid primary key references public.sun_workspaces(id) on delete cascade, + plan_id text not null references public.sun_plans(id), + status text not null default 'trialing' check (status in ('trialing','active','past_due','canceled','expired')), + trial_started_at timestamptz null, + trial_ends_at timestamptz null, + current_period_start timestamptz null, + current_period_end timestamptz null, + grace_until timestamptz null, + cancel_at_period_end boolean not null default false, + source text not null default 'manual', + external_customer_id text null, + external_subscription_id text null, + note text null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists public.sun_workspace_feature_overrides ( + workspace_id uuid not null references public.sun_workspaces(id) on delete cascade, + feature_key text not null, + enabled boolean not null, + expires_at timestamptz null, + note text null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + primary key (workspace_id, feature_key) +); + +create table if not exists public.sun_platform_admins ( + user_id uuid primary key references auth.users(id) on delete cascade, + created_at timestamptz not null default now() +); + +insert into public.sun_plans(id,name,description,max_members,sort_order,is_active) values +('basic','Базовый','Заказы, календарь, клиенты, просмотр каталога и базовая статистика.',1,10,true), +('professional','Профессиональный','Основная работа команды, финансы, склад, поставщики, маршруты, рассылки и фирменное оформление.',3,20,true), +('full','Полный','Все функции, включая редактирование боксов и предложения клиентам.',null,30,true) +on conflict (id) do update set name=excluded.name,description=excluded.description,max_members=excluded.max_members,sort_order=excluded.sort_order,is_active=excluded.is_active,updated_at=now(); + +-- Reset only built-in feature matrix for the three standard plans. +delete from public.sun_plan_features where plan_id in ('basic','professional','full'); + +insert into public.sun_plan_features(plan_id,feature_key,enabled) values +-- Basic +('basic','orders',true),('basic','calendar',true),('basic','map',true),('basic','clients',true),('basic','catalog_view',true), +('basic','catalog_edit',false),('basic','production',false),('basic','shopping',false),('basic','stock',false),('basic','routes',false), +('basic','mailings',false),('basic','money',false),('basic','stats_basic',true),('basic','stats_advanced',false),('basic','team',false), +('basic','suppliers',false),('basic','print',true),('basic','settings',true),('basic','branding',false),('basic','client_offers',false), +('basic','offer_templates',false),('basic','backups',false),('basic','audit',false),('basic','users_manage',false), +-- Professional +('professional','orders',true),('professional','calendar',true),('professional','map',true),('professional','clients',true),('professional','catalog_view',true), +('professional','catalog_edit',false),('professional','production',true),('professional','shopping',true),('professional','stock',true),('professional','routes',true), +('professional','mailings',true),('professional','money',true),('professional','stats_basic',true),('professional','stats_advanced',true),('professional','team',true), +('professional','suppliers',true),('professional','print',true),('professional','settings',true),('professional','branding',true),('professional','client_offers',false), +('professional','offer_templates',false),('professional','backups',true),('professional','audit',false),('professional','users_manage',true), +-- Full +('full','orders',true),('full','calendar',true),('full','map',true),('full','clients',true),('full','catalog_view',true), +('full','catalog_edit',true),('full','production',true),('full','shopping',true),('full','stock',true),('full','routes',true), +('full','mailings',true),('full','money',true),('full','stats_basic',true),('full','stats_advanced',true),('full','team',true), +('full','suppliers',true),('full','print',true),('full','settings',true),('full','branding',true),('full','client_offers',true), +('full','offer_templates',true),('full','backups',true),('full','audit',true),('full','users_manage',true); + +-- The creator of the oldest/current production workspace is the initial SaaS platform owner. +insert into public.sun_platform_admins(user_id) +select created_by from public.sun_workspaces where created_by is not null order by created_at asc limit 1 +on conflict (user_id) do nothing; + +-- Existing workspaces are never disrupted by this migration: they receive Full active access. +insert into public.sun_workspace_subscriptions(workspace_id,plan_id,status,current_period_start,current_period_end,grace_until,source,note) +select id,'full','active',now(),timestamptz '2099-12-31 23:59:59+00',timestamptz '2100-01-07 23:59:59+00','migration','Existing workspace preserved during SaaS migration' +from public.sun_workspaces +on conflict (workspace_id) do nothing; + +alter table public.sun_plans enable row level security; +alter table public.sun_plan_features enable row level security; +alter table public.sun_workspace_subscriptions enable row level security; +alter table public.sun_workspace_feature_overrides enable row level security; +alter table public.sun_platform_admins enable row level security; + +-- Recreate policies idempotently. +drop policy if exists sun_plans_read on public.sun_plans; +create policy sun_plans_read on public.sun_plans for select to authenticated using (is_active = true); + +drop policy if exists sun_plan_features_read on public.sun_plan_features; +create policy sun_plan_features_read on public.sun_plan_features for select to authenticated using (true); + +drop policy if exists sun_workspace_subscriptions_member_read on public.sun_workspace_subscriptions; +create policy sun_workspace_subscriptions_member_read on public.sun_workspace_subscriptions for select to authenticated +using (exists(select 1 from public.sun_workspace_members m where m.workspace_id=sun_workspace_subscriptions.workspace_id and m.user_id=auth.uid() and m.is_active=true)); + +drop policy if exists sun_workspace_overrides_member_read on public.sun_workspace_feature_overrides; +create policy sun_workspace_overrides_member_read on public.sun_workspace_feature_overrides for select to authenticated +using (exists(select 1 from public.sun_workspace_members m where m.workspace_id=sun_workspace_feature_overrides.workspace_id and m.user_id=auth.uid() and m.is_active=true)); + +-- No direct client access to platform-admin rows. Use checked RPCs only. +revoke all on public.sun_platform_admins from anon, authenticated; + +create or replace function public.sun_is_platform_admin() +returns boolean +language sql +stable +security definer +set search_path='public' +as $$ + select auth.uid() is not null and exists(select 1 from public.sun_platform_admins a where a.user_id=auth.uid()); +$$; + +create or replace function public.sun_subscription_access_mode(p_workspace uuid) +returns text +language plpgsql +stable +security definer +set search_path='public' +as $$ +declare + s public.sun_workspace_subscriptions%rowtype; + v_end timestamptz; + v_grace timestamptz; +begin + if public.sun_member_role(p_workspace) is null and not public.sun_is_platform_admin() then return 'blocked'; end if; + select * into s from public.sun_workspace_subscriptions where workspace_id=p_workspace; + if not found then return 'blocked'; end if; + + if s.status='trialing' then + v_end:=s.trial_ends_at; + else + v_end:=s.current_period_end; + end if; + v_grace:=coalesce(s.grace_until, case when v_end is not null then v_end + interval '7 days' else null end); + + if s.status in ('trialing','active','canceled') and (v_end is null or now() <= v_end) then return 'full'; end if; + if v_grace is not null and now() <= v_grace then return 'read_only'; end if; + return 'blocked'; +end; +$$; + +create or replace function public.sun_workspace_has_feature(p_workspace uuid,p_feature text) +returns boolean +language plpgsql +stable +security definer +set search_path='public' +as $$ +declare + v_plan text; + v_value boolean := false; + v_override boolean; +begin + if public.sun_member_role(p_workspace) is null and not public.sun_is_platform_admin() then return false; end if; + select plan_id into v_plan from public.sun_workspace_subscriptions where workspace_id=p_workspace; + if v_plan is null then return false; end if; + select enabled into v_value from public.sun_plan_features where plan_id=v_plan and feature_key=p_feature; + select enabled into v_override from public.sun_workspace_feature_overrides + where workspace_id=p_workspace and feature_key=p_feature and (expires_at is null or expires_at>now()); + if found then return coalesce(v_override,false); end if; + return coalesce(v_value,false); +end; +$$; + +create or replace function public.sun_subscription_snapshot(p_workspace uuid) +returns table( + workspace_id uuid, + plan_id text, + plan_name text, + status text, + access_mode text, + trial_ends_at timestamptz, + current_period_end timestamptz, + grace_until timestamptz, + max_members integer, + member_count integer, + features jsonb, + platform_admin boolean +) +language plpgsql +stable +security definer +set search_path='public' +as $$ +declare + s public.sun_workspace_subscriptions%rowtype; + p public.sun_plans%rowtype; + f jsonb := '{}'::jsonb; + o jsonb := '{}'::jsonb; +begin + if public.sun_member_role(p_workspace) is null and not public.sun_is_platform_admin() then raise exception 'Access denied'; end if; + select * into s from public.sun_workspace_subscriptions where sun_workspace_subscriptions.workspace_id=p_workspace; + if not found then raise exception 'Subscription not found'; end if; + select * into p from public.sun_plans where id=s.plan_id; + select coalesce(jsonb_object_agg(feature_key,enabled),'{}'::jsonb) into f from public.sun_plan_features where sun_plan_features.plan_id=s.plan_id; + select coalesce(jsonb_object_agg(feature_key,enabled),'{}'::jsonb) into o from public.sun_workspace_feature_overrides + where sun_workspace_feature_overrides.workspace_id=p_workspace and (expires_at is null or expires_at>now()); + workspace_id:=p_workspace; plan_id:=s.plan_id; plan_name:=p.name; status:=s.status; access_mode:=public.sun_subscription_access_mode(p_workspace); + trial_ends_at:=s.trial_ends_at; current_period_end:=s.current_period_end; grace_until:=coalesce(s.grace_until,coalesce(s.trial_ends_at,s.current_period_end)+interval '7 days'); + max_members:=p.max_members; + select count(*)::int into member_count from public.sun_workspace_members m where m.workspace_id=p_workspace and m.is_active=true; + features:=f||o; platform_admin:=public.sun_is_platform_admin(); + return next; +end; +$$; + +create or replace function public.sun_feature_for_storage_key(p_key text) +returns text +language sql +immutable +set search_path='public' +as $$ + select case + when p_key='sunOrders' then 'orders' + when p_key in ('sunBoxes','sunCatalogCategoriesV2','sunOfficialCatalogVersion') then 'catalog_edit' + when p_key in ('sunClientLoyaltyV1','sunClientCommunicationV1') then 'clients' + when p_key='sunFinanceRecordsV2' then 'money' + when p_key in ('sunStock','sunStockMoves') then 'stock' + when p_key='sunEmployees' then 'team' + when p_key='sunSuppliers' then 'suppliers' + when p_key like 'sunRoute%' then 'routes' + when p_key like 'sunMarketing%' or p_key='sunPromoCodesV1' then 'mailings' + when p_key='sunBrandThemeV1' then 'branding' + when p_key='sunClientOfferSettingsV1' then 'client_offers' + when p_key='sunOfferTemplateV1' then 'offer_templates' + else 'settings' + end; +$$; + +-- New workspaces automatically receive a 14-day Full trial. +create or replace function public.sun_create_workspace(p_name text default 'Солнце Кейтеринг'::text) +returns uuid +language plpgsql +security definer +set search_path='public' +as $$ +declare + v_user uuid := auth.uid(); + v_workspace uuid; + v_name text; +begin + if v_user is null then raise exception 'Authentication required'; end if; + v_name := coalesce(nullif(trim(p_name),''),'Солнце Кейтеринг'); + insert into public.sun_workspaces(name, created_by) values (v_name, v_user) returning id into v_workspace; + insert into public.sun_workspace_members(workspace_id,user_id,role,display_name,is_active,permissions) + values (v_workspace,v_user,'admin',coalesce((select raw_user_meta_data->>'name' from auth.users where id=v_user),split_part(coalesce((select email from auth.users where id=v_user),'Администратор'),'@',1)),true,public.sun_role_default_permissions('admin')); + insert into public.sun_app_state(workspace_id,payload,client_id) + values (v_workspace,'{"format":"sun-cloud-v2","version":2,"storage":{}}'::jsonb,'bootstrap') + on conflict (workspace_id) do nothing; + insert into public.sun_workspace_subscriptions(workspace_id,plan_id,status,trial_started_at,trial_ends_at,grace_until,source,note) + values(v_workspace,'full','trialing',now(),now()+interval '14 days',now()+interval '21 days','trial','14-day Full trial') + on conflict(workspace_id) do nothing; + return v_workspace; +end; +$$; + +-- Workspace member limits are enforced server-side. +create or replace function public.sun_create_invite(p_workspace uuid, p_role text default 'manager'::text) +returns uuid +language plpgsql +security definer +set search_path='public' +as $$ +declare + v_token uuid; + v_role text := lower(coalesce(p_role,'manager')); + v_max integer; + v_count integer; +begin + if public.sun_subscription_access_mode(p_workspace)<>'full' then raise exception 'Подписка не позволяет изменять пользователей'; end if; + if not public.sun_workspace_has_feature(p_workspace,'users_manage') then raise exception 'Добавление сотрудников недоступно на текущем тарифе'; end if; + if not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Administrator permission required'; end if; + if v_role not in ('admin','manager','kitchen','courier','viewer') then raise exception 'Invalid role'; end if; + select p.max_members into v_max from public.sun_workspace_subscriptions s join public.sun_plans p on p.id=s.plan_id where s.workspace_id=p_workspace; + select count(*)::int into v_count from public.sun_workspace_members where workspace_id=p_workspace and is_active=true; + if v_max is not null and v_count>=v_max then raise exception 'Достигнут лимит сотрудников тарифа (%).',v_max; end if; + insert into public.sun_workspace_invites(workspace_id,role,permissions,created_by) + values (p_workspace,v_role,public.sun_role_default_permissions(v_role),auth.uid()) returning token into v_token; + return v_token; +end; +$$; + +create or replace function public.sun_accept_invite(p_token uuid) +returns uuid +language plpgsql +security definer +set search_path='public' +as $$ +declare + v_user uuid := auth.uid(); + v_invite public.sun_workspace_invites%rowtype; + v_display text; + v_max integer; + v_count integer; +begin + if v_user is null then raise exception 'Authentication required'; end if; + select * into v_invite from public.sun_workspace_invites where token=p_token for update; + if not found then raise exception 'Invite not found'; end if; + if v_invite.used_at is not null then raise exception 'Invite already used'; end if; + if v_invite.expires_at < now() then raise exception 'Invite expired'; end if; + if public.sun_subscription_access_mode(v_invite.workspace_id)<>'full' then raise exception 'Подписка компании неактивна'; end if; + if not public.sun_workspace_has_feature(v_invite.workspace_id,'users_manage') then raise exception 'Сотрудники недоступны на текущем тарифе'; end if; + select p.max_members into v_max from public.sun_workspace_subscriptions s join public.sun_plans p on p.id=s.plan_id where s.workspace_id=v_invite.workspace_id; + select count(*)::int into v_count from public.sun_workspace_members where workspace_id=v_invite.workspace_id and is_active=true; + if not exists(select 1 from public.sun_workspace_members where workspace_id=v_invite.workspace_id and user_id=v_user and is_active=true) then + if v_max is not null and v_count>=v_max then raise exception 'Достигнут лимит сотрудников тарифа (%).',v_max; end if; + end if; + select coalesce(nullif(raw_user_meta_data->>'name',''),split_part(coalesce(email,'Сотрудник'),'@',1)) into v_display from auth.users where id=v_user; + insert into public.sun_workspace_members(workspace_id,user_id,role,display_name,is_active,permissions,updated_at) + values (v_invite.workspace_id,v_user,v_invite.role,v_display,true,coalesce(v_invite.permissions,public.sun_role_default_permissions(v_invite.role)),now()) + on conflict (workspace_id,user_id) do update set role=excluded.role,display_name=coalesce(public.sun_workspace_members.display_name,excluded.display_name),is_active=true,permissions=excluded.permissions,updated_at=now(); + update public.sun_workspace_invites set used_by=v_user,used_at=now() where token=p_token; + return v_invite.workspace_id; +end; +$$; + +-- Subscription gating is enforced on the cloud save RPC, not only in the browser. +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; +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; + 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; +$$; + +-- Platform owner APIs. Direct table access remains blocked for normal users. +create or replace function public.sun_platform_list_workspaces() +returns table(workspace_id uuid,workspace_name text,created_at timestamptz,plan_id text,plan_name text,status text,access_mode text,trial_ends_at timestamptz,current_period_end timestamptz,grace_until timestamptz,member_count integer,max_members integer) +language plpgsql +stable +security definer +set search_path='public' +as $$ +begin + if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if; + return query + select w.id,w.name,w.created_at,s.plan_id,p.name,s.status,public.sun_subscription_access_mode(w.id),s.trial_ends_at,s.current_period_end, + coalesce(s.grace_until,coalesce(s.trial_ends_at,s.current_period_end)+interval '7 days'), + (select count(*)::int from public.sun_workspace_members m where m.workspace_id=w.id and m.is_active=true),p.max_members + from public.sun_workspaces w + left join public.sun_workspace_subscriptions s on s.workspace_id=w.id + left join public.sun_plans p on p.id=s.plan_id + order by w.created_at desc; +end; +$$; + +create or replace function public.sun_platform_set_subscription(p_workspace uuid,p_plan text,p_days integer default 30,p_status text default 'active') +returns void +language plpgsql +security definer +set search_path='public' +as $$ +declare + v_status text:=lower(coalesce(p_status,'active')); +begin + if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if; + if not exists(select 1 from public.sun_plans where id=p_plan and is_active=true) then raise exception 'Unknown plan'; end if; + if v_status not in ('trialing','active','past_due','canceled','expired') then raise exception 'Invalid status'; end if; + if p_days is null or p_days<0 or p_days>3650 then raise exception 'Invalid duration'; end if; + insert into public.sun_workspace_subscriptions(workspace_id,plan_id,status,current_period_start,current_period_end,grace_until,source,updated_at) + values(p_workspace,p_plan,v_status,now(),case when v_status='expired' then now() else now()+make_interval(days=>p_days) end, + case when v_status='expired' then now() else now()+make_interval(days=>p_days+7) end,'manual',now()) + on conflict(workspace_id) do update set plan_id=excluded.plan_id,status=excluded.status,current_period_start=excluded.current_period_start,current_period_end=excluded.current_period_end,grace_until=excluded.grace_until,source='manual',updated_at=now(); +end; +$$; + +create or replace function public.sun_platform_set_feature_override(p_workspace uuid,p_feature text,p_enabled boolean,p_days integer default null,p_note text default null) +returns void +language plpgsql +security definer +set search_path='public' +as $$ +begin + if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if; + if not exists(select 1 from public.sun_plan_features where feature_key=p_feature) then raise exception 'Unknown feature'; end if; + insert into public.sun_workspace_feature_overrides(workspace_id,feature_key,enabled,expires_at,note,updated_at) + values(p_workspace,p_feature,p_enabled,case when p_days is null then null else now()+make_interval(days=>p_days) end,p_note,now()) + on conflict(workspace_id,feature_key) do update set enabled=excluded.enabled,expires_at=excluded.expires_at,note=excluded.note,updated_at=now(); +end; +$$; + +revoke all on function public.sun_is_platform_admin() from public; +revoke all on function public.sun_subscription_access_mode(uuid) from public; +revoke all on function public.sun_workspace_has_feature(uuid,text) from public; +revoke all on function public.sun_subscription_snapshot(uuid) from public; +revoke all on function public.sun_platform_list_workspaces() from public; +revoke all on function public.sun_platform_set_subscription(uuid,text,integer,text) from public; +revoke all on function public.sun_platform_set_feature_override(uuid,text,boolean,integer,text) from public; + +grant execute on function public.sun_is_platform_admin() to authenticated; +grant execute on function public.sun_subscription_access_mode(uuid) to authenticated; +grant execute on function public.sun_workspace_has_feature(uuid,text) to authenticated; +grant execute on function public.sun_subscription_snapshot(uuid) to authenticated; +grant execute on function public.sun_platform_list_workspaces() to authenticated; +grant execute on function public.sun_platform_set_subscription(uuid,text,integer,text) to authenticated; +grant execute on function public.sun_platform_set_feature_override(uuid,text,boolean,integer,text) to authenticated; + + +-- Source: ops/sql/SUPABASE-SAAS-V16-FINALIZE.sql +-- Sun Catering SaaS v16 finalization +-- Run after SUPABASE-SAAS-V16.sql on a fresh project. + +create or replace function public.sun_feature_for_read_storage_key(p_key text) +returns text +language sql +immutable +set search_path='public' +as $$ + select case + when p_key='sunOrders' then 'orders' + when p_key in ('sunBoxes','sunCatalogCategoriesV2','sunOfficialCatalogVersion') then 'catalog_view' + when p_key in ('sunClientLoyaltyV1','sunClientCommunicationV1') then 'clients' + when p_key='sunFinanceRecordsV2' then 'money' + when p_key in ('sunStock','sunStockMoves') then 'stock' + when p_key='sunEmployees' then 'team' + when p_key='sunSuppliers' then 'suppliers' + when p_key like 'sunRoute%' then 'routes' + when p_key like 'sunMarketing%' or p_key='sunPromoCodesV1' then 'mailings' + when p_key='sunBrandThemeV1' then 'branding' + when p_key='sunClientOfferSettingsV1' then 'client_offers' + when p_key='sunOfferTemplateV1' then 'offer_templates' + when p_key='sunAuditLogV1' then 'audit' + else 'settings' + end; +$$; + +create or replace function public.sun_fetch_app_state(p_workspace uuid) +returns table(workspace_id uuid, payload jsonb, revision bigint, updated_at timestamptz, client_id text) +language plpgsql +stable security definer +set search_path='public' +as $$ +declare + v_row public.sun_app_state%rowtype; + v_storage jsonb := '{}'::jsonb; + kv record; + v_feature text; +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)='blocked' then raise exception 'Подписка закончилась. Данные сохранены, продлите подписку для доступа.'; end if; + select * into v_row from public.sun_app_state where sun_app_state.workspace_id=p_workspace; + if not found then return; end if; + for kv in select key,value from jsonb_each(coalesce(v_row.payload->'storage','{}'::jsonb)) loop + v_feature := public.sun_feature_for_read_storage_key(kv.key); + if public.sun_can_read_storage_key(p_workspace,kv.key) + and (v_feature is null or public.sun_workspace_has_feature(p_workspace,v_feature)) then + v_storage := v_storage || jsonb_build_object(kv.key,kv.value); + end if; + end loop; + workspace_id := v_row.workspace_id; + payload := jsonb_build_object('format',coalesce(v_row.payload->'format','"sun-cloud-v2"'::jsonb),'version',coalesce(v_row.payload->'version','2'::jsonb),'storage',v_storage); + revision := v_row.revision; + updated_at := v_row.updated_at; + client_id := v_row.client_id; + return next; +end; +$$; + +create or replace function public.sun_admin_update_member(p_workspace uuid, p_user uuid, p_display_name text, p_role text, p_is_active boolean, p_permissions jsonb) +returns void +language plpgsql +security definer +set search_path='public' +as $$ +declare + v_old_role text; + v_old_active boolean; + v_admins int; + v_role text := lower(coalesce(p_role,'')); + v_max integer; + v_active_count integer; +begin + if public.sun_subscription_access_mode(p_workspace)<>'full' then raise exception 'Подписка не позволяет изменять пользователей'; end if; + if not public.sun_workspace_has_feature(p_workspace,'users_manage') then raise exception 'Управление сотрудниками недоступно на текущем тарифе'; end if; + if not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Administrator permission required'; end if; + if v_role not in ('admin','manager','kitchen','courier','viewer') then raise exception 'Invalid role'; end if; + if p_permissions is null or jsonb_typeof(p_permissions) <> 'object' then raise exception 'Permissions must be an object'; end if; + select role,is_active into v_old_role,v_old_active from public.sun_workspace_members where workspace_id=p_workspace and user_id=p_user for update; + if not found then raise exception 'Member not found'; end if; + if v_old_role='admin' and coalesce(v_old_active,false) and (v_role<>'admin' or not coalesce(p_is_active,false)) then + select count(*) into v_admins from public.sun_workspace_members where workspace_id=p_workspace and role='admin' and is_active=true; + if v_admins <= 1 then raise exception 'Нельзя отключить или понизить последнего администратора'; end if; + end if; + if coalesce(p_is_active,false) and not coalesce(v_old_active,false) then + select p.max_members into v_max from public.sun_workspace_subscriptions s join public.sun_plans p on p.id=s.plan_id where s.workspace_id=p_workspace; + select count(*)::int into v_active_count from public.sun_workspace_members where workspace_id=p_workspace and is_active=true; + if v_max is not null and v_active_count>=v_max then raise exception 'Достигнут лимит сотрудников тарифа (%).',v_max; end if; + end if; + update public.sun_workspace_members set display_name=nullif(trim(coalesce(p_display_name,'')),''),role=v_role,is_active=coalesce(p_is_active,false),permissions=p_permissions,updated_at=now() + where workspace_id=p_workspace and user_id=p_user; +end; +$$; + +create or replace function public.sun_admin_remove_member(p_workspace uuid, p_user uuid) +returns void +language plpgsql +security definer +set search_path='public' +as $$ +declare + v_role text; + v_active boolean; + v_admins int; +begin + if public.sun_subscription_access_mode(p_workspace)<>'full' then raise exception 'Подписка не позволяет изменять пользователей'; end if; + if not public.sun_workspace_has_feature(p_workspace,'users_manage') then raise exception 'Управление сотрудниками недоступно на текущем тарифе'; end if; + if not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Administrator permission required'; end if; + select role,is_active into v_role,v_active from public.sun_workspace_members where workspace_id=p_workspace and user_id=p_user for update; + if not found then return; end if; + if v_role='admin' and coalesce(v_active,false) then + select count(*) into v_admins from public.sun_workspace_members where workspace_id=p_workspace and role='admin' and is_active=true; + if v_admins <= 1 then raise exception 'Нельзя удалить последнего администратора'; end if; + end if; + delete from public.sun_workspace_members where workspace_id=p_workspace and user_id=p_user; +end; +$$; + +-- New SaaS helper RPCs are authenticated-only. +revoke execute on function public.sun_is_platform_admin() from public, anon; +revoke execute on function public.sun_subscription_access_mode(uuid) from public, anon; +revoke execute on function public.sun_workspace_has_feature(uuid,text) from public, anon; +revoke execute on function public.sun_subscription_snapshot(uuid) from public, anon; +revoke execute on function public.sun_platform_list_workspaces() from public, anon; +revoke execute on function public.sun_platform_set_subscription(uuid,text,integer,text) from public, anon; +revoke execute on function public.sun_platform_set_feature_override(uuid,text,boolean,integer,text) from public, anon; + +grant execute on function public.sun_is_platform_admin() to authenticated; +grant execute on function public.sun_subscription_access_mode(uuid) to authenticated; +grant execute on function public.sun_workspace_has_feature(uuid,text) to authenticated; +grant execute on function public.sun_subscription_snapshot(uuid) to authenticated; +grant execute on function public.sun_platform_list_workspaces() to authenticated; +grant execute on function public.sun_platform_set_subscription(uuid,text,integer,text) to authenticated; +grant execute on function public.sun_platform_set_feature_override(uuid,text,boolean,integer,text) to authenticated; + + +-- Source: ops/sql/SUPABASE-FRESH-V17-FOUNDATION.sql +-- Reconstructed from the production client and retained SQL, 2026-09-17. +-- Empty-project recovery only; original v17 foundation was not in Git history. +create table public.sun_v17_orders ( + workspace_id uuid not null references public.sun_workspaces(id) on delete cascade, + order_id text not null, data jsonb not null, version bigint not null default 1, + created_at timestamptz not null default now(), updated_at timestamptz not null default now(), + updated_by uuid references auth.users(id) on delete set null, primary key(workspace_id,order_id) +); +create table public.sun_v17_catalog_items ( + workspace_id uuid not null references public.sun_workspaces(id) on delete cascade, + item_id text not null, data jsonb not null, version bigint not null default 1, + created_at timestamptz not null default now(), updated_at timestamptz not null default now(), + updated_by uuid references auth.users(id) on delete set null, primary key(workspace_id,item_id) +); +create table public.sun_v17_clients ( + workspace_id uuid not null references public.sun_workspaces(id) on delete cascade, + client_key text not null,name text,phone text,latest_address text,data jsonb not null default '{}', + version bigint not null default 1,created_at timestamptz not null default now(),updated_at timestamptz not null default now(), + primary key(workspace_id,client_key) +); +create table public.sun_v17_settings ( + workspace_id uuid not null references public.sun_workspaces(id) on delete cascade, + key text not null,value jsonb,version bigint not null default 1,updated_at timestamptz not null default now(), + primary key(workspace_id,key) +); +create table public.sun_v17_workspace_meta ( + workspace_id uuid primary key references public.sun_workspaces(id) on delete cascade, + schema_version integer not null default 17,last_backup_on date,legacy_revision bigint not null default 0, + migrated_at timestamptz not null default now(),updated_at timestamptz not null default now() +); +create table public.sun_v17_change_events ( + id bigint generated by default as identity primary key, + workspace_id uuid not null references public.sun_workspaces(id) on delete cascade, + entity text not null,entity_key text,operation text not null,version bigint,client_id text, + created_by uuid references auth.users(id) on delete set null,created_at timestamptz not null default now() +); +create index sun_v17_changes_workspace_id_idx on public.sun_v17_change_events(workspace_id,id); +create table public.sun_v17_backups ( + id uuid primary key default gen_random_uuid(),workspace_id uuid not null references public.sun_workspaces(id) on delete cascade, + kind text not null,label text not null default '',snapshot jsonb not null, + created_by uuid references auth.users(id) on delete set null,created_at timestamptz not null default now() +); +create index sun_v17_backups_workspace_created_idx on public.sun_v17_backups(workspace_id,created_at desc); +create table public.sun_v17_error_events ( + id uuid primary key default gen_random_uuid(),workspace_id uuid references public.sun_workspaces(id) on delete cascade, + user_id uuid references auth.users(id) on delete set null,client_id text,app_version text,level text,message text, + stack text,context jsonb not null default '{}',created_at timestamptz not null default now() +); +create index sun_v17_errors_workspace_created_idx on public.sun_v17_error_events(workspace_id,created_at desc); +create table public.caterium_company_owner_invites ( + token uuid primary key default gen_random_uuid(),workspace_id uuid not null references public.sun_workspaces(id) on delete cascade, + email text not null,created_at timestamptz not null default now(),expires_at timestamptz not null default now()+interval '7 days', + used_at timestamptz,used_by uuid references auth.users(id) on delete set null +); + +do $$ declare t text; begin + foreach t in array array['sun_v17_orders','sun_v17_catalog_items','sun_v17_clients','sun_v17_settings','sun_v17_workspace_meta','sun_v17_change_events','sun_v17_backups','sun_v17_error_events','caterium_company_owner_invites'] loop + execute format('alter table public.%I enable row level security',t); + execute format('revoke all on public.%I from anon,authenticated',t); + end loop; +end $$; +-- Browser data access uses the permission-checked RPCs. Realtime only reveals an event. +grant select on public.sun_v17_change_events to authenticated; +create policy sun_v17_change_read on public.sun_v17_change_events for select to authenticated using(public.sun_member_role(workspace_id) is not null); +alter publication supabase_realtime add table public.sun_v17_change_events; + +create function public.sun_my_workspaces() +returns table(id uuid,name text,role text,display_name text,is_active boolean,permissions jsonb) +language sql stable security definer set search_path=public as $$ + select w.id,w.name,m.role,m.display_name,m.is_active,public.sun_role_default_permissions(m.role)||coalesce(m.permissions,'{}') + from public.sun_workspace_members m join public.sun_workspaces w on w.id=m.workspace_id + where m.user_id=auth.uid() and m.is_active order by w.created_at,w.id +$$; + +create function public.sun_v17_mirror_legacy(p_workspace uuid,p_payload jsonb,p_client_id text default null) +returns void language plpgsql security definer set search_path=public as $$ +declare canonical jsonb; r record; v_data jsonb; v_ids text[]; v_key text; v_rev bigint; +begin + if public.sun_member_role(p_workspace) is null and not public.sun_is_platform_admin() then raise exception 'Access denied'; end if; + perform 1 from public.sun_workspaces where id=p_workspace for update; + select payload,revision into canonical,v_rev from public.sun_app_state where workspace_id=p_workspace; + if canonical is null then return; end if; + -- Always mirror the validated server row. The supplied legacy argument is never trusted. + for r in select * from (values ('sunOrders','sun_v17_orders','order_id','order'),('sunBoxes','sun_v17_catalog_items','item_id','catalog')) as x(storage_key,table_name,id_column,entity) loop + v_data:=coalesce(canonical#>array['storage',r.storage_key,'v'],'[]'::jsonb); + if jsonb_typeof(v_data)<>'array' then raise exception 'Invalid entity array: %',r.storage_key; end if; + if exists(select 1 from jsonb_array_elements(v_data) e where nullif(e->>'id','') is null) then raise exception 'Entity ID is required'; end if; + if (select count(*) from jsonb_array_elements(v_data))<>(select count(distinct e->>'id') from jsonb_array_elements(v_data) e) then raise exception 'Duplicate entity ID'; end if; + select coalesce(array_agg(e->>'id'),'{}') into v_ids from jsonb_array_elements(v_data) e; + execute format('with gone as (delete from public.%I where workspace_id=$1 and not (%I=any($2)) returning %I,version) insert into public.sun_v17_change_events(workspace_id,entity,entity_key,operation,version,client_id,created_by) select $1,$3,%I,''delete'',version+1,$4,auth.uid() from gone',r.table_name,r.id_column,r.id_column,r.id_column) using p_workspace,v_ids,r.entity,p_client_id; + execute format('with saved as (insert into public.%I as dest(workspace_id,%I,data,updated_by) select $1,e->>''id'',e,auth.uid() from jsonb_array_elements($2) e on conflict(workspace_id,%I) do update set data=excluded.data,version=dest.version+1,updated_at=now(),updated_by=auth.uid() where dest.data is distinct from excluded.data returning %I,version) insert into public.sun_v17_change_events(workspace_id,entity,entity_key,operation,version,client_id,created_by) select $1,$3,%I,''upsert'',version,$4,auth.uid() from saved',r.table_name,r.id_column,r.id_column,r.id_column,r.id_column) using p_workspace,v_data,r.entity,p_client_id; + end loop; + delete from public.sun_v17_settings where workspace_id=p_workspace and not (canonical->'storage' ? key); + insert into public.sun_v17_settings as dest(workspace_id,key,value) + select p_workspace,key,value from jsonb_each(coalesce(canonical->'storage','{}')) where key not in ('sunOrders','sunBoxes') + on conflict(workspace_id,key) do update set value=excluded.value,version=dest.version+1,updated_at=now() where dest.value is distinct from excluded.value; + insert into public.sun_v17_workspace_meta(workspace_id,legacy_revision) values(p_workspace,v_rev) + on conflict(workspace_id) do update set legacy_revision=excluded.legacy_revision,updated_at=now(); +end $$; + +create function public.sun_save_app_state_v17(p_workspace uuid,p_payload jsonb,p_client_id text,p_expected_revision bigint default null) +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 current_revision bigint; +begin + if public.sun_member_role(p_workspace) is null then raise exception 'Access denied'; end if; + perform 1 from public.sun_workspaces w where w.id=p_workspace for update; + select s.revision into current_revision from public.sun_app_state s where s.workspace_id=p_workspace; + if p_expected_revision is not null and coalesce(current_revision,0)<>p_expected_revision then + raise exception using errcode='40001',message=format('SUN_CONFLICT expected=%s actual=%s',p_expected_revision,coalesce(current_revision,0)); + end if; + perform public.sun_save_app_state(p_workspace,p_payload,p_client_id); + perform public.sun_v17_mirror_legacy(p_workspace,p_payload,p_client_id); + return query select * from public.sun_fetch_app_state(p_workspace); +end $$; + +create function public.sun_v17_build_snapshot(p_workspace uuid) +returns jsonb language sql stable security definer set search_path=public as $$ + select jsonb_build_object('version',17,'legacy',payload,'revision',revision,'created_at',now()) from public.sun_app_state where workspace_id=p_workspace +$$; +create function public.sun_v17_prune_backups(p_workspace uuid,p_keep integer default 30) +returns integer language plpgsql security definer set search_path=public as $$ +declare n integer; +begin + if public.sun_member_role(p_workspace) is null and not public.sun_is_platform_admin() then raise exception 'Access denied'; end if; + delete from public.sun_v17_backups where workspace_id=p_workspace and kind='daily' and id in + (select id from public.sun_v17_backups where workspace_id=p_workspace and kind='daily' order by created_at desc offset greatest(30,coalesce(p_keep,30))); + get diagnostics n=row_count; return n; +end $$; + +create function public.sun_workspace_access_mode_internal_v28(p_workspace uuid) returns text language sql stable security definer set search_path=public as $$select public.sun_subscription_access_mode(p_workspace)$$; +create function public.sun_workspace_feature_internal_v28(p_workspace uuid,p_feature text) returns boolean language sql stable security definer set search_path=public as $$select public.sun_workspace_has_feature(p_workspace,p_feature)$$; + +revoke all on function public.sun_v17_build_snapshot(uuid),public.sun_workspace_access_mode_internal_v28(uuid),public.sun_workspace_feature_internal_v28(uuid,text) from public,anon,authenticated; +revoke all on function public.sun_my_workspaces(),public.sun_save_app_state_v17(uuid,jsonb,text,bigint),public.sun_v17_mirror_legacy(uuid,jsonb,text),public.sun_v17_prune_backups(uuid,integer) from public,anon; +grant execute on function public.sun_my_workspaces(),public.sun_save_app_state_v17(uuid,jsonb,text,bigint),public.sun_v17_mirror_legacy(uuid,jsonb,text),public.sun_v17_prune_backups(uuid,integer) to authenticated; + + +-- Source: ops/sql/SUPABASE-V17.8-DEVELOPER-PANEL.sql +-- Sun Catering v17.8 developer-panel additions. +-- Applied to the current project on 2026-09-01. + +create or replace function public.sun_platform_list_users() +returns table( + user_id uuid, + email text, + last_sign_in_at timestamptz, + created_at timestamptz, + workspace_id uuid, + workspace_name text, + role text, + display_name text, + is_active boolean, + is_platform_admin boolean +) +language plpgsql +stable +security definer +set search_path='public' +as $$ +begin + if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if; + return query + select u.id,u.email::text,u.last_sign_in_at,u.created_at,m.workspace_id,w.name,m.role,m.display_name,m.is_active, + exists(select 1 from public.sun_platform_admins p where p.user_id=u.id) + from auth.users u + left join public.sun_workspace_members m on m.user_id=u.id + left join public.sun_workspaces w on w.id=m.workspace_id + order by coalesce(w.name,''),coalesce(m.display_name,u.email),u.email; +end; +$$; +revoke all on function public.sun_platform_list_users() from public; +grant execute on function public.sun_platform_list_users() to authenticated; + +create or replace function public.sun_v17_create_backup(p_workspace uuid, p_kind text default 'manual'::text, p_label text default ''::text) +returns uuid +language plpgsql +security definer +set search_path='public' +as $$ +declare v_id uuid; v_kind text:=lower(coalesce(p_kind,'manual')); +begin + if public.sun_member_role(p_workspace) is null and not public.sun_is_platform_admin() then raise exception 'Access denied'; end if; + if v_kind not in ('daily','manual','pre_restore','migration') then raise exception 'Invalid backup kind'; end if; + if v_kind<>'daily' and not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if; + if v_kind='daily' and exists(select 1 from public.sun_v17_backups where workspace_id=p_workspace and kind='daily' and created_at::date=current_date) then + select id into v_id from public.sun_v17_backups where workspace_id=p_workspace and kind='daily' and created_at::date=current_date order by created_at desc limit 1; + return v_id; + end if; + insert into public.sun_v17_backups(workspace_id,kind,label,snapshot,created_by) + values(p_workspace,v_kind,left(coalesce(p_label,''),240),public.sun_v17_build_snapshot(p_workspace),auth.uid()) returning id into v_id; + insert into public.sun_v17_workspace_meta(workspace_id,last_backup_on,updated_at) values(p_workspace,current_date,now()) + on conflict(workspace_id) do update set last_backup_on=current_date,updated_at=now(); + return v_id; +end; +$$; + +create or replace function public.sun_v17_restore_backup(p_workspace uuid, p_backup uuid) +returns void +language plpgsql +security definer +set search_path='public' +as $$ +declare s jsonb; legacy jsonb; +begin + if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if; + perform public.sun_v17_create_backup(p_workspace,'pre_restore','Автоматически перед восстановлением'); + select snapshot into s from public.sun_v17_backups where id=p_backup and workspace_id=p_workspace; + if s is null then raise exception 'Backup not found'; end if; + legacy:=s->'legacy'; + if legacy is null or jsonb_typeof(legacy)<>'object' then raise exception 'Backup has no legacy state'; end if; + perform public.sun_save_app_state(p_workspace,legacy,'backup-restore'); + perform public.sun_v17_mirror_legacy(p_workspace,legacy,'backup-restore'); + insert into public.sun_v17_change_events(workspace_id,entity,entity_key,operation,client_id,created_by) + values(p_workspace,'workspace','backup','restore','backup-restore',auth.uid()); +end; +$$; +revoke all on function public.sun_v17_create_backup(uuid,text,text) from public; +revoke all on function public.sun_v17_restore_backup(uuid,uuid) from public; +grant execute on function public.sun_v17_create_backup(uuid,text,text) to authenticated; +grant execute on function public.sun_v17_restore_backup(uuid,uuid) to authenticated; + +-- Company owner can attach an already registered account by email without invite codes. +create or replace function public.sun_owner_add_existing_member(p_workspace uuid,p_email text,p_role text default 'manager') +returns uuid +language plpgsql +security definer +set search_path='public' +as $$ +declare + v_user uuid; + v_role text:=lower(coalesce(p_role,'manager')); + v_display text; + v_max integer; + v_count integer; +begin + if auth.uid() is null then raise exception 'Authentication required'; end if; + if public.sun_member_role(p_workspace)<>'admin' and not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Нет права управлять сотрудниками'; end if; + if public.sun_subscription_access_mode(p_workspace)<>'full' then raise exception 'Подписка не позволяет изменять пользователей'; end if; + if not public.sun_workspace_has_feature(p_workspace,'users_manage') then raise exception 'Сотрудники недоступны на текущем тарифе'; end if; + if v_role not in ('manager','kitchen','courier','viewer') then raise exception 'Для сотрудника выберите рабочую роль'; end if; + select id,coalesce(nullif(raw_user_meta_data->>'name',''),split_part(email,'@',1)) into v_user,v_display + from auth.users where lower(email)=lower(trim(p_email)) limit 1; + if v_user is null then raise exception 'Аккаунт с таким email ещё не зарегистрирован'; end if; + select p.max_members into v_max from public.sun_workspace_subscriptions s join public.sun_plans p on p.id=s.plan_id where s.workspace_id=p_workspace; + select count(*)::int into v_count from public.sun_workspace_members where workspace_id=p_workspace and is_active=true; + if not exists(select 1 from public.sun_workspace_members where workspace_id=p_workspace and user_id=v_user and is_active=true) then + if v_max is not null and v_count>=v_max then raise exception 'Достигнут лимит сотрудников тарифа (%)',v_max; end if; + end if; + insert into public.sun_workspace_members(workspace_id,user_id,role,display_name,is_active,permissions,updated_at) + values(p_workspace,v_user,v_role,v_display,true,public.sun_role_default_permissions(v_role),now()) + on conflict(workspace_id,user_id) do update set role=excluded.role,display_name=coalesce(public.sun_workspace_members.display_name,excluded.display_name),is_active=true,permissions=excluded.permissions,updated_at=now(); + return v_user; +end; +$$; +revoke all on function public.sun_owner_add_existing_member(uuid,text,text) from public; +grant execute on function public.sun_owner_add_existing_member(uuid,text,text) to authenticated; + + +-- Source: ops/sql/SUPABASE-V17.9-DEVELOPER-SETTINGS.sql +-- Sun Catering v17.9 developer settings. +-- Safe, additive migration for the existing v17 schema. + +-- The platform administrator can inspect normalized health for any company. +-- Ordinary users remain limited to companies where they are active members. +create or replace function public.sun_v17_entity_snapshot(p_workspace uuid) +returns jsonb +language plpgsql +stable +security definer +set search_path='public' +as $$ +begin + if public.sun_member_role(p_workspace) is null and not public.sun_is_platform_admin() then + raise exception 'Access denied'; + end if; + return jsonb_build_object( + 'orders',coalesce(( + select jsonb_agg(jsonb_build_object('id',order_id,'version',version,'data',data,'updated_at',updated_at) order by order_id) + from public.sun_v17_orders where workspace_id=p_workspace + ),'[]'::jsonb), + 'catalog',coalesce(( + select jsonb_agg(jsonb_build_object('id',item_id,'version',version,'data',data,'updated_at',updated_at) order by item_id) + from public.sun_v17_catalog_items where workspace_id=p_workspace + ),'[]'::jsonb), + 'meta',coalesce(( + select to_jsonb(meta) from public.sun_v17_workspace_meta meta where workspace_id=p_workspace + ),'{}'::jsonb) + ); +end; +$$; + +-- The platform administrator can list backups for any company from the +-- protected developer panel. Members can still list their own company's rows. +create or replace function public.sun_v17_list_backups(p_workspace uuid, p_limit integer default 30) +returns table(id uuid, kind text, label text, created_at timestamptz, created_by uuid) +language plpgsql +stable +security definer +set search_path='public' +as $$ +begin + if public.sun_member_role(p_workspace) is null and not public.sun_is_platform_admin() then + raise exception 'Access denied'; + end if; + return query + select backup.id,backup.kind,backup.label,backup.created_at,backup.created_by + from public.sun_v17_backups backup + where backup.workspace_id=p_workspace + order by backup.created_at desc + limit greatest(1,least(coalesce(p_limit,30),100)); +end; +$$; + +-- Global technical error directory. It intentionally excludes stack/context +-- from the browser result; those fields can contain sensitive implementation data. +create or replace function public.sun_platform_list_errors(p_workspace uuid default null, p_limit integer default 80) +returns table( + id uuid, + workspace_id uuid, + workspace_name text, + user_id uuid, + client_id text, + app_version text, + level text, + message text, + created_at timestamptz +) +language plpgsql +stable +security definer +set search_path='public' +as $$ +begin + if not public.sun_is_platform_admin() then + raise exception 'Platform administrator required'; + end if; + return query + select err.id,err.workspace_id,company.name,err.user_id,err.client_id,err.app_version,err.level,err.message,err.created_at + from public.sun_v17_error_events err + left join public.sun_workspaces company on company.id=err.workspace_id + where p_workspace is null or err.workspace_id=p_workspace + order by err.created_at desc + limit greatest(1,least(coalesce(p_limit,80),200)); +end; +$$; + +revoke all on function public.sun_v17_entity_snapshot(uuid) from public, anon; +revoke all on function public.sun_v17_list_backups(uuid,integer) from public, anon; +revoke all on function public.sun_platform_list_errors(uuid,integer) from public, anon; +grant execute on function public.sun_v17_entity_snapshot(uuid) to authenticated; +grant execute on function public.sun_v17_list_backups(uuid,integer) to authenticated; +grant execute on function public.sun_platform_list_errors(uuid,integer) to authenticated; + + +-- Source: ops/sql/SUPABASE-DEVELOPER-V22.sql +-- Caterium / Sun Catering v17.5.22 developer console +-- Additive only. Gives platform admins a server-enforced developer console API. + +create table if not exists public.sun_platform_audit_events ( + id bigint generated by default as identity primary key, + actor_user_id uuid null references auth.users(id) on delete set null, + action text not null, + target_workspace_id uuid null references public.sun_workspaces(id) on delete set null, + target_user_id uuid null references auth.users(id) on delete set null, + details jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now() +); + +create index if not exists sun_platform_audit_events_created_idx on public.sun_platform_audit_events(created_at desc); +create index if not exists sun_platform_audit_events_workspace_idx on public.sun_platform_audit_events(target_workspace_id,created_at desc); +create index if not exists sun_platform_audit_events_user_idx on public.sun_platform_audit_events(target_user_id,created_at desc); +alter table public.sun_platform_audit_events enable row level security; +revoke all on public.sun_platform_audit_events from anon, authenticated; + +create or replace function public.sun_platform_log_event( + p_action text, + p_workspace uuid default null, + p_user uuid default null, + p_details jsonb default '{}'::jsonb +) returns bigint +language plpgsql +security definer +set search_path='public','auth' +as $$ +declare v_id bigint; +begin + if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if; + if nullif(trim(coalesce(p_action,'')),'') is null then raise exception 'Action required'; end if; + insert into public.sun_platform_audit_events(actor_user_id,action,target_workspace_id,target_user_id,details) + values(auth.uid(),left(trim(p_action),120),p_workspace,p_user,coalesce(p_details,'{}'::jsonb)) returning id into v_id; + return v_id; +end; +$$; + +create or replace function public.sun_platform_dashboard() +returns jsonb +language plpgsql +stable +security definer +set search_path='public','auth' +as $$ +declare + v_companies bigint; v_accounts bigint; v_admins bigint; v_active bigint; v_trial bigint; v_locked bigint; + v_errors bigint; v_backups bigint; v_members bigint; v_last_state timestamptz; v_last_backup timestamptz; +begin + if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if; + select count(*) into v_companies from public.sun_workspaces; + select count(*) into v_accounts from auth.users; + select count(*) into v_admins from public.sun_platform_admins; + select count(*) into v_members from public.sun_workspace_members where is_active=true; + select count(*) into v_active from public.sun_workspace_subscriptions where status='active' and coalesce(current_period_end,'infinity'::timestamptz)>now(); + select count(*) into v_trial from public.sun_workspace_subscriptions where status='trialing' and coalesce(trial_ends_at,'infinity'::timestamptz)>now(); + select count(*) into v_locked from public.sun_workspaces w where public.sun_subscription_access_mode(w.id) in ('read_only','blocked'); + select count(*) into v_errors from public.sun_v17_error_events where created_at>now()-interval '24 hours'; + select count(*) into v_backups from public.sun_v17_backups where created_at>now()-interval '24 hours'; + select max(updated_at) into v_last_state from public.sun_app_state; + select max(created_at) into v_last_backup from public.sun_v17_backups; + return jsonb_build_object( + 'companies',v_companies,'accounts',v_accounts,'platform_admins',v_admins,'memberships',v_members, + 'active_subscriptions',v_active,'trials',v_trial,'restricted_companies',v_locked, + 'errors_24h',v_errors,'backups_24h',v_backups,'last_state_at',v_last_state,'last_backup_at',v_last_backup, + 'database_size',pg_size_pretty(pg_database_size(current_database())), + 'postgres_version',current_setting('server_version') + ); +end; +$$; + +create or replace function public.sun_platform_list_activity(p_limit integer default 120) +returns table( + id bigint, action text, actor_user_id uuid, actor_email text, + workspace_id uuid, workspace_name text, target_user_id uuid, target_email text, + details jsonb, created_at timestamptz +) +language plpgsql +stable +security definer +set search_path='public','auth' +as $$ +begin + if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if; + return query + select a.id,a.action,a.actor_user_id,au.email::text,a.target_workspace_id,w.name,a.target_user_id,tu.email::text,a.details,a.created_at + from public.sun_platform_audit_events a + left join auth.users au on au.id=a.actor_user_id + left join public.sun_workspaces w on w.id=a.target_workspace_id + left join auth.users tu on tu.id=a.target_user_id + order by a.created_at desc + limit greatest(1,least(coalesce(p_limit,120),500)); +end; +$$; + +create or replace function public.sun_platform_support_snapshot(p_workspace uuid) +returns table(workspace_id uuid,payload jsonb,revision bigint,updated_at timestamptz,client_id text) +language plpgsql +security definer +set search_path='public' +as $$ +begin + if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if; + if not exists(select 1 from public.sun_workspaces where id=p_workspace) then raise exception 'Workspace not found'; end if; + perform public.sun_platform_log_event('support.open',p_workspace,null,jsonb_build_object('mode','read_only')); + return query + select s.workspace_id,s.payload,s.revision,s.updated_at,s.client_id + from public.sun_app_state s where s.workspace_id=p_workspace; +end; +$$; + +create or replace function public.sun_platform_workspace_diagnostics(p_workspace uuid) +returns jsonb +language plpgsql +stable +security definer +set search_path='public' +as $$ +declare v jsonb; +begin + if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if; + if not exists(select 1 from public.sun_workspaces where id=p_workspace) then raise exception 'Workspace not found'; end if; + select jsonb_build_object( + 'workspace_id',w.id,'name',w.name,'created_at',w.created_at, + 'revision',coalesce(s.revision,0),'state_updated_at',s.updated_at,'state_client_id',s.client_id, + 'members',(select count(*) from public.sun_workspace_members m where m.workspace_id=w.id and m.is_active), + 'orders',(select count(*) from public.sun_v17_orders o where o.workspace_id=w.id), + 'clients',(select count(*) from public.sun_v17_clients c where c.workspace_id=w.id), + 'catalog_items',(select count(*) from public.sun_v17_catalog_items c where c.workspace_id=w.id), + 'settings',(select count(*) from public.sun_v17_settings x where x.workspace_id=w.id), + 'backups',(select count(*) from public.sun_v17_backups b where b.workspace_id=w.id), + 'latest_backup',(select max(created_at) from public.sun_v17_backups b where b.workspace_id=w.id), + 'errors_24h',(select count(*) from public.sun_v17_error_events e where e.workspace_id=w.id and e.created_at>now()-interval '24 hours'), + 'latest_error',(select max(created_at) from public.sun_v17_error_events e where e.workspace_id=w.id), + 'access_mode',public.sun_subscription_access_mode(w.id) + ) into v + from public.sun_workspaces w left join public.sun_app_state s on s.workspace_id=w.id + where w.id=p_workspace; + return v; +end; +$$; + +create or replace function public.sun_platform_list_workspace_features(p_workspace uuid) +returns table( + feature_key text, plan_enabled boolean, override_enabled boolean, + override_expires_at timestamptz, effective_enabled boolean, note text +) +language plpgsql +stable +security definer +set search_path='public' +as $$ +declare v_plan text; +begin + if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if; + select plan_id into v_plan from public.sun_workspace_subscriptions where workspace_id=p_workspace; + return query + with features as ( + select distinct pf.feature_key from public.sun_plan_features pf + ) + select f.feature_key, + coalesce(pf.enabled,false), + case when o.expires_at is null or o.expires_at>now() then o.enabled else null end, + o.expires_at, + case when o.feature_key is not null and (o.expires_at is null or o.expires_at>now()) then o.enabled else coalesce(pf.enabled,false) end, + o.note + from features f + left join public.sun_plan_features pf on pf.plan_id=v_plan and pf.feature_key=f.feature_key + left join public.sun_workspace_feature_overrides o on o.workspace_id=p_workspace and o.feature_key=f.feature_key + order by f.feature_key; +end; +$$; + +create or replace function public.sun_platform_set_plan_feature(p_plan text,p_feature text,p_enabled boolean) +returns void +language plpgsql +security definer +set search_path='public' +as $$ +begin + if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if; + if not exists(select 1 from public.sun_plans where id=p_plan) then raise exception 'Unknown plan'; end if; + if nullif(trim(coalesce(p_feature,'')),'') is null then raise exception 'Feature required'; end if; + insert into public.sun_plan_features(plan_id,feature_key,enabled) values(p_plan,p_feature,p_enabled) + on conflict(plan_id,feature_key) do update set enabled=excluded.enabled; + perform public.sun_platform_log_event('plan.feature.set',null,null,jsonb_build_object('plan',p_plan,'feature',p_feature,'enabled',p_enabled)); +end; +$$; + +create or replace function public.sun_platform_set_plan_max_members(p_plan text,p_max_members integer) +returns void +language plpgsql +security definer +set search_path='public' +as $$ +begin + if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if; + if p_max_members is not null and (p_max_members<1 or p_max_members>10000) then raise exception 'Invalid member limit'; end if; + update public.sun_plans set max_members=p_max_members,updated_at=now() where id=p_plan; + if not found then raise exception 'Unknown plan'; end if; + perform public.sun_platform_log_event('plan.members.set',null,null,jsonb_build_object('plan',p_plan,'max_members',p_max_members)); +end; +$$; + +create or replace function public.sun_platform_seed_workspace_catalog( + p_workspace uuid, + p_boxes jsonb, + p_catalog_version text default '', + p_replace boolean default false +) returns integer +language plpgsql +security definer +set search_path='public' +as $$ +declare + v_payload jsonb; v_existing jsonb; v_entry jsonb; v_item jsonb; v_id text; v_count integer:=0; +begin + if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if; + if jsonb_typeof(p_boxes)<>'array' then raise exception 'Catalog must be an array'; end if; + if not exists(select 1 from public.sun_workspaces where id=p_workspace) then raise exception 'Workspace not found'; end if; + + select payload into v_payload from public.sun_app_state where workspace_id=p_workspace for update; + if v_payload is null then + v_payload:=jsonb_build_object('format','sun-cloud-v2','version',2,'storage','{}'::jsonb); + insert into public.sun_app_state(workspace_id,payload,revision,updated_by) + values(p_workspace,v_payload,0,auth.uid()) + on conflict(workspace_id) do nothing; + end if; + + v_existing:=coalesce(v_payload->'storage'->'sunBoxes'->'v','[]'::jsonb); + if not p_replace and jsonb_typeof(v_existing)='array' and jsonb_array_length(v_existing)>0 then + raise exception 'Catalog already populated'; + end if; + + v_entry:=jsonb_build_object('t','j','v',p_boxes); + v_payload:=jsonb_set(coalesce(v_payload,'{}'::jsonb),'{storage,sunBoxes}',v_entry,true); + if nullif(coalesce(p_catalog_version,''),'') is not null then + v_payload:=jsonb_set(v_payload,'{storage,sunOfficialCatalogVersion}',jsonb_build_object('t','s','v',p_catalog_version),true); + end if; + update public.sun_app_state set payload=v_payload,revision=revision+1,client_id='platform-catalog',updated_by=auth.uid(),updated_at=now() where workspace_id=p_workspace; + + if p_replace then delete from public.sun_v17_catalog_items where workspace_id=p_workspace; end if; + for v_item in select value from jsonb_array_elements(p_boxes) loop + v_id:=coalesce(nullif(v_item->>'id',''),gen_random_uuid()::text); + insert into public.sun_v17_catalog_items(workspace_id,item_id,data,version,created_by,updated_by) + values(p_workspace,v_id,v_item,1,auth.uid(),auth.uid()) + on conflict(workspace_id,item_id) do update set data=excluded.data,version=sun_v17_catalog_items.version+1,updated_by=auth.uid(),updated_at=now(); + v_count:=v_count+1; + end loop; + + insert into public.sun_v17_change_events(workspace_id,entity,entity_key,operation,client_id,created_by) + values(p_workspace,'catalog','starter',case when p_replace then 'replace' else 'seed' end,'platform-catalog',auth.uid()); + perform public.sun_platform_log_event(case when p_replace then 'catalog.force_apply' else 'catalog.seed' end,p_workspace,null,jsonb_build_object('count',v_count,'version',p_catalog_version)); + return v_count; +end; +$$; + +revoke all on function public.sun_platform_log_event(text,uuid,uuid,jsonb) from public, anon; +revoke all on function public.sun_platform_dashboard() from public, anon; +revoke all on function public.sun_platform_list_activity(integer) from public, anon; +revoke all on function public.sun_platform_support_snapshot(uuid) from public, anon; +revoke all on function public.sun_platform_workspace_diagnostics(uuid) from public, anon; +revoke all on function public.sun_platform_list_workspace_features(uuid) from public, anon; +revoke all on function public.sun_platform_set_plan_feature(text,text,boolean) from public, anon; +revoke all on function public.sun_platform_set_plan_max_members(text,integer) from public, anon; +revoke all on function public.sun_platform_seed_workspace_catalog(uuid,jsonb,text,boolean) from public, anon; + +grant execute on function public.sun_platform_log_event(text,uuid,uuid,jsonb) to authenticated; +grant execute on function public.sun_platform_dashboard() to authenticated; +grant execute on function public.sun_platform_list_activity(integer) to authenticated; +grant execute on function public.sun_platform_support_snapshot(uuid) to authenticated; +grant execute on function public.sun_platform_workspace_diagnostics(uuid) to authenticated; +grant execute on function public.sun_platform_list_workspace_features(uuid) to authenticated; +grant execute on function public.sun_platform_set_plan_feature(text,text,boolean) to authenticated; +grant execute on function public.sun_platform_set_plan_max_members(text,integer) to authenticated; +grant execute on function public.sun_platform_seed_workspace_catalog(uuid,jsonb,text,boolean) to authenticated; + +create or replace function public.sun_platform_reset_feature_override(p_workspace uuid,p_feature text) +returns void +language plpgsql +security definer +set search_path='public' +as $$ +begin + if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if; + delete from public.sun_workspace_feature_overrides where workspace_id=p_workspace and feature_key=p_feature; + perform public.sun_platform_log_event('feature.override.reset',p_workspace,null,jsonb_build_object('feature',p_feature)); +end; +$$; + +create or replace function public.sun_platform_create_company_v22( + p_name text, + p_owner_email text default null, + p_plan text default 'full', + p_days integer default 30, + p_boxes jsonb default '[]'::jsonb, + p_catalog_version text default '' +) returns jsonb +language plpgsql +security definer +set search_path='public' +as $$ +declare + v_base jsonb; v_ws uuid; v_profile jsonb; v_payload jsonb; v_count integer; +begin + if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if; + select public.caterium_platform_create_company(p_name,p_owner_email,p_plan,p_days,'empty') into v_base; + v_ws:=(v_base->>'workspace_id')::uuid; + v_profile:=jsonb_build_object('name',coalesce(nullif(trim(p_name),''),'Новая компания'),'ownerEmail',coalesce(p_owner_email,''),'createdAt',now()); + select payload into v_payload from public.sun_app_state where workspace_id=v_ws for update; + v_payload:=jsonb_set(v_payload,'{storage,sunCompanyProfileV1}',jsonb_build_object('t','j','v',v_profile),true); + v_payload:=jsonb_set(v_payload,'{storage,sunOrders}',jsonb_build_object('t','j','v','[]'::jsonb),true); + v_payload:=jsonb_set(v_payload,'{storage,sunClientsV2}',jsonb_build_object('t','j','v','[]'::jsonb),true); + update public.sun_app_state set payload=v_payload,updated_by=auth.uid(),updated_at=now() where workspace_id=v_ws; + select public.sun_platform_seed_workspace_catalog(v_ws,coalesce(p_boxes,'[]'::jsonb),p_catalog_version,true) into v_count; + perform public.sun_platform_log_event('company.create',v_ws,null,jsonb_build_object('name',p_name,'owner_email',p_owner_email,'plan',p_plan,'days',p_days,'catalog_count',v_count)); + return coalesce(v_base,'{}'::jsonb)||jsonb_build_object('catalog_count',v_count); +end; +$$; + +revoke all on function public.sun_platform_reset_feature_override(uuid,text) from public, anon; +revoke all on function public.sun_platform_create_company_v22(text,text,text,integer,jsonb,text) from public, anon; +grant execute on function public.sun_platform_reset_feature_override(uuid,text) to authenticated; +grant execute on function public.sun_platform_create_company_v22(text,text,text,integer,jsonb,text) to authenticated; + + +-- Source: ops/sql/SUPABASE-DEVELOPER-V22-AAL2.sql +-- Developer console v22 hardening: platform-wide actions require MFA AAL2. + +create or replace function public.sun_require_platform_admin_aal2() +returns void +language plpgsql +security definer +set search_path='public','auth' +as $$ +begin + if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if; + if coalesce(auth.jwt()->>'aal','aal1') <> 'aal2' then raise exception 'Developer MFA AAL2 required'; end if; +end; +$$; +revoke all on function public.sun_require_platform_admin_aal2() from public, anon, authenticated; + +create or replace function public.sun_platform_list_companies_v22() +returns table(workspace_id uuid,workspace_name text,created_at timestamptz,plan_id text,plan_name text,status text,access_mode text,trial_ends_at timestamptz,current_period_end timestamptz,grace_until timestamptz,member_count bigint,max_members integer,owner_email text,pending_owner_email text) +language plpgsql stable security definer set search_path='public','auth' as $$ +begin + perform public.sun_require_platform_admin_aal2(); + return query + select w.id,w.name,w.created_at,s.plan_id,p.name,s.status,public.sun_subscription_access_mode(w.id),s.trial_ends_at,s.current_period_end,s.grace_until, + (select count(*) from public.sun_workspace_members m where m.workspace_id=w.id and m.is_active),p.max_members, + (select u.email::text from public.sun_workspace_members m join auth.users u on u.id=m.user_id where m.workspace_id=w.id and m.role='admin' and m.is_active order by m.created_at asc limit 1), + (select i.email from public.caterium_company_owner_invites i where i.workspace_id=w.id and i.used_at is null and i.expires_at>now() order by i.created_at desc limit 1) + from public.sun_workspaces w left join public.sun_workspace_subscriptions s on s.workspace_id=w.id left join public.sun_plans p on p.id=s.plan_id order by w.created_at desc; +end;$$; + +create or replace function public.sun_platform_list_users_v22() +returns table(user_id uuid,email text,last_sign_in_at timestamptz,created_at timestamptz,workspace_id uuid,workspace_name text,role text,display_name text,is_active boolean,is_platform_admin boolean) +language plpgsql stable security definer set search_path='public','auth' as $$ +begin + perform public.sun_require_platform_admin_aal2(); + return query select u.id,u.email::text,u.last_sign_in_at,u.created_at,m.workspace_id,w.name,m.role,m.display_name,m.is_active, + exists(select 1 from public.sun_platform_admins p where p.user_id=u.id) + from auth.users u left join public.sun_workspace_members m on m.user_id=u.id left join public.sun_workspaces w on w.id=m.workspace_id + order by coalesce(w.name,''),coalesce(m.display_name,u.email),u.email; +end;$$; + +create or replace function public.sun_platform_list_errors_v22(p_workspace uuid default null,p_limit integer default 80) +returns table(id uuid,workspace_id uuid,workspace_name text,user_id uuid,client_id text,app_version text,level text,message text,created_at timestamptz) +language plpgsql stable security definer set search_path='public' as $$ +begin + perform public.sun_require_platform_admin_aal2(); + return query select err.id,err.workspace_id,company.name,err.user_id,err.client_id,err.app_version,err.level,err.message,err.created_at + from public.sun_v17_error_events err left join public.sun_workspaces company on company.id=err.workspace_id + where p_workspace is null or err.workspace_id=p_workspace order by err.created_at desc limit greatest(1,least(coalesce(p_limit,80),200)); +end;$$; + +-- Harden v22 developer functions. +create or replace function public.sun_platform_dashboard() +returns jsonb language plpgsql stable security definer set search_path='public','auth' as $$ +declare v_companies bigint; v_accounts bigint; v_admins bigint; v_active bigint; v_trial bigint; v_locked bigint; v_errors bigint; v_backups bigint; v_members bigint; v_last_state timestamptz; v_last_backup timestamptz; +begin + perform public.sun_require_platform_admin_aal2(); + select count(*) into v_companies from public.sun_workspaces; select count(*) into v_accounts from auth.users; select count(*) into v_admins from public.sun_platform_admins; select count(*) into v_members from public.sun_workspace_members where is_active=true; + select count(*) into v_active from public.sun_workspace_subscriptions where status='active' and coalesce(current_period_end,'infinity'::timestamptz)>now(); select count(*) into v_trial from public.sun_workspace_subscriptions where status='trialing' and coalesce(trial_ends_at,'infinity'::timestamptz)>now(); + select count(*) into v_locked from public.sun_workspaces w where public.sun_subscription_access_mode(w.id) in ('read_only','blocked'); select count(*) into v_errors from public.sun_v17_error_events where created_at>now()-interval '24 hours'; select count(*) into v_backups from public.sun_v17_backups where created_at>now()-interval '24 hours'; select max(updated_at) into v_last_state from public.sun_app_state; select max(created_at) into v_last_backup from public.sun_v17_backups; + return jsonb_build_object('companies',v_companies,'accounts',v_accounts,'platform_admins',v_admins,'memberships',v_members,'active_subscriptions',v_active,'trials',v_trial,'restricted_companies',v_locked,'errors_24h',v_errors,'backups_24h',v_backups,'last_state_at',v_last_state,'last_backup_at',v_last_backup,'database_size',pg_size_pretty(pg_database_size(current_database())),'postgres_version',current_setting('server_version')); +end;$$; + +-- Change the v22 function bodies by replacing the first platform check with the AAL2 guard. +-- Definitions are kept in SUPABASE-DEVELOPER-V22.sql; this block updates privileges for secure wrappers. +revoke all on function public.sun_platform_list_users() from anon; +revoke all on function public.sun_owner_add_existing_member(uuid,text,text) from anon; +revoke all on function public.sun_platform_list_companies_v22() from public,anon; +revoke all on function public.sun_platform_list_users_v22() from public,anon; +revoke all on function public.sun_platform_list_errors_v22(uuid,integer) from public,anon; +grant execute on function public.sun_platform_list_companies_v22() to authenticated; +grant execute on function public.sun_platform_list_users_v22() to authenticated; +grant execute on function public.sun_platform_list_errors_v22(uuid,integer) to authenticated; + + +-- Source: ops/sql/SUPABASE-REGISTRATION-USERS-V27.sql +-- Caterium v17.5.27: clear registration + email-bound employee invitations + +alter table public.sun_workspace_invites add column if not exists email text; +alter table public.sun_workspace_invites add column if not exists display_name text; + +create index if not exists sun_workspace_invites_pending_email_v27_idx + on public.sun_workspace_invites(workspace_id, lower(email)) + where used_at is null; + +-- Public Caterium signups and invite signups do not require clicking an email-confirmation link. +-- This keeps the existing Auth signup endpoint/rate limits while marking these app-originated users verified. +create or replace function public.caterium_autoconfirm_signup_v25() +returns trigger +language plpgsql +security definer +set search_path = 'auth','public' +as $$ +begin + if coalesce(new.raw_user_meta_data->>'registration_source','') in ('caterium_public_signup','caterium_invite_signup') then + new.email_confirmed_at := coalesce(new.email_confirmed_at, now()); + new.raw_user_meta_data := jsonb_set(coalesce(new.raw_user_meta_data,'{}'::jsonb),'{email_verified}','true'::jsonb,true); + end if; + return new; +end; +$$; + +create or replace function public.caterium_autoverify_identity_v25() +returns trigger +language plpgsql +security definer +set search_path = 'auth','public' +as $$ +begin + if new.provider='email' and exists ( + select 1 from auth.users u + where u.id=new.user_id + and coalesce(u.raw_user_meta_data->>'registration_source','') in ('caterium_public_signup','caterium_invite_signup') + ) then + new.identity_data := jsonb_set(coalesce(new.identity_data,'{}'::jsonb),'{email_verified}','true'::jsonb,true); + end if; + return new; +end; +$$; + +create or replace function public.sun_create_invite_v27( + p_workspace uuid, + p_email text, + p_display_name text default '', + p_role text default 'manager' +) +returns uuid +language plpgsql +security definer +set search_path='public','auth' +as $$ +declare + v_token uuid; + v_role text := lower(coalesce(p_role,'manager')); + v_email text := lower(trim(coalesce(p_email,''))); + v_name text := trim(coalesce(p_display_name,'')); + v_max integer; + v_active integer; + v_pending integer; +begin + if public.sun_subscription_access_mode(p_workspace)<>'full' then raise exception 'Подписка не позволяет изменять пользователей'; end if; + if not public.sun_workspace_has_feature(p_workspace,'users_manage') then raise exception 'Добавление сотрудников недоступно на текущем тарифе'; end if; + if not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Недостаточно прав для добавления пользователей'; end if; + if v_role not in ('admin','manager','kitchen','courier','viewer') then raise exception 'Некорректная роль'; end if; + if v_email='' or v_email !~* '^[^[:space:]@]+@[^[:space:]@]+\.[^[:space:]@]+$' then raise exception 'Введите корректный email сотрудника'; end if; + if v_name='' then v_name:=split_part(v_email,'@',1); end if; + + if exists( + select 1 + from public.sun_workspace_members m + join auth.users u on u.id=m.user_id + where m.workspace_id=p_workspace and m.is_active=true and lower(coalesce(u.email,''))=v_email + ) then + raise exception 'Пользователь с этим email уже добавлен в компанию'; + end if; + + -- Remove stale invites and replace the previous pending invite for the same email. + delete from public.sun_workspace_invites + where workspace_id=p_workspace and used_at is null + and (expires_at<=now() or lower(coalesce(email,''))=v_email); + + select p.max_members into v_max + from public.sun_workspace_subscriptions s + join public.sun_plans p on p.id=s.plan_id + where s.workspace_id=p_workspace; + + if v_max is not null then + select count(*)::int into v_active + from public.sun_workspace_members + where workspace_id=p_workspace and is_active=true; + select count(*)::int into v_pending + from public.sun_workspace_invites + where workspace_id=p_workspace and used_at is null and expires_at>now(); + if v_active+v_pending>=v_max then + raise exception 'Достигнут лимит пользователей тарифа (%)',v_max; + end if; + end if; + + insert into public.sun_workspace_invites(workspace_id,role,permissions,created_by,email,display_name) + values(p_workspace,v_role,public.sun_role_default_permissions(v_role),auth.uid(),v_email,left(v_name,120)) + returning token into v_token; + return v_token; +end; +$$; + +create or replace function public.sun_list_workspace_invites_v27(p_workspace uuid) +returns table( + token uuid, + email text, + display_name text, + role text, + created_at timestamptz, + expires_at timestamptz, + status text +) +language plpgsql +stable +security definer +set search_path='public' +as $$ +begin + if not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Недостаточно прав для просмотра приглашений'; end if; + return query + select i.token,i.email,i.display_name,i.role,i.created_at,i.expires_at, + case when i.expires_at<=now() then 'expired' else 'pending' end::text + from public.sun_workspace_invites i + where i.workspace_id=p_workspace and i.used_at is null + order by i.created_at desc; +end; +$$; + +create or replace function public.sun_cancel_invite_v27(p_workspace uuid,p_token uuid) +returns boolean +language plpgsql +security definer +set search_path='public' +as $$ +declare v_deleted integer; +begin + if not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Недостаточно прав для отмены приглашения'; end if; + delete from public.sun_workspace_invites + where workspace_id=p_workspace and token=p_token and used_at is null; + get diagnostics v_deleted = row_count; + return v_deleted>0; +end; +$$; + +-- Token is the secret. This preview intentionally reveals only the invited company/name/email/role. +create or replace function public.sun_invite_preview_v27(p_token uuid) +returns table( + workspace_id uuid, + workspace_name text, + email text, + display_name text, + role text, + expires_at timestamptz, + is_valid boolean +) +language sql +stable +security definer +set search_path='public' +as $$ + select w.id,w.name,i.email,i.display_name,i.role,i.expires_at, + (i.used_at is null and i.expires_at>now()) as is_valid + from public.sun_workspace_invites i + join public.sun_workspaces w on w.id=i.workspace_id + where i.token=p_token + limit 1 +$$; + +-- Keep the existing RPC name for backward compatibility, but make new email-bound invites safe. +create or replace function public.sun_accept_invite(p_token uuid) +returns uuid +language plpgsql +security definer +set search_path='public','auth' +as $$ +declare + v_user uuid := auth.uid(); + v_invite public.sun_workspace_invites%rowtype; + v_display text; + v_user_email text; + v_max integer; + v_count integer; +begin + if v_user is null then raise exception 'Сначала войдите в Caterium'; end if; + select * into v_invite from public.sun_workspace_invites where token=p_token for update; + if not found then raise exception 'Приглашение не найдено'; end if; + if v_invite.used_at is not null then raise exception 'Приглашение уже использовано'; end if; + if v_invite.expires_at < now() then raise exception 'Срок действия приглашения истёк'; end if; + + select lower(coalesce(email,'')), + coalesce(nullif(v_invite.display_name,''),nullif(raw_user_meta_data->>'name',''),split_part(coalesce(email,'Сотрудник'),'@',1)) + into v_user_email,v_display + from auth.users where id=v_user; + + if nullif(lower(trim(coalesce(v_invite.email,''))),'') is not null + and lower(trim(v_invite.email))<>v_user_email then + raise exception 'Это приглашение создано для другого email'; + end if; + + if public.sun_subscription_access_mode(v_invite.workspace_id)<>'full' then raise exception 'Подписка компании неактивна'; end if; + if not public.sun_workspace_has_feature(v_invite.workspace_id,'users_manage') then raise exception 'Добавление сотрудников недоступно на текущем тарифе'; end if; + + select p.max_members into v_max + from public.sun_workspace_subscriptions s + join public.sun_plans p on p.id=s.plan_id + where s.workspace_id=v_invite.workspace_id; + select count(*)::int into v_count + from public.sun_workspace_members + where workspace_id=v_invite.workspace_id and is_active=true; + if not exists(select 1 from public.sun_workspace_members where workspace_id=v_invite.workspace_id and user_id=v_user and is_active=true) then + if v_max is not null and v_count>=v_max then raise exception 'Достигнут лимит пользователей тарифа (%)',v_max; end if; + end if; + + insert into public.sun_workspace_members(workspace_id,user_id,role,display_name,is_active,permissions,updated_at) + values(v_invite.workspace_id,v_user,v_invite.role,v_display,true,coalesce(v_invite.permissions,public.sun_role_default_permissions(v_invite.role)),now()) + on conflict(workspace_id,user_id) do update + set role=excluded.role, + display_name=coalesce(nullif(public.sun_workspace_members.display_name,''),excluded.display_name), + is_active=true, + permissions=excluded.permissions, + updated_at=now(); + + update public.sun_workspace_invites set used_by=v_user,used_at=now() where token=p_token; + return v_invite.workspace_id; +end; +$$; + +revoke all on function public.sun_create_invite_v27(uuid,text,text,text) from public,anon; +revoke all on function public.sun_list_workspace_invites_v27(uuid) from public,anon; +revoke all on function public.sun_cancel_invite_v27(uuid,uuid) from public,anon; +revoke all on function public.sun_invite_preview_v27(uuid) from public; +revoke all on function public.sun_accept_invite(uuid) from public,anon; + +grant execute on function public.sun_create_invite_v27(uuid,text,text,text) to authenticated; +grant execute on function public.sun_list_workspace_invites_v27(uuid) to authenticated; +grant execute on function public.sun_cancel_invite_v27(uuid,uuid) to authenticated; +grant execute on function public.sun_invite_preview_v27(uuid) to anon,authenticated; +grant execute on function public.sun_accept_invite(uuid) to authenticated; + + +-- Source: ops/sql/SUPABASE-EMPLOYEE-MANAGEMENT-V28.sql +-- Caterium v17.7.4-ish: employee invitation/creation RPCs backing the +-- caterium-create-employee Edge Function. +-- +-- These were applied directly to the production database and were missing +-- from the repository (found during the 2026-09-12 security/infra audit). +-- Recorded here for auditability, verbatim via pg_get_functiondef() against +-- the live database on 2026-09-12 — not re-applied as part of this commit. +-- +-- Authorization model: both are SECURITY DEFINER but re-check the caller's +-- own privileges internally (auth.uid(), caterium_is_workspace_owner / +-- sun_is_platform_admin, plan/feature gates, member limits) before doing +-- anything privileged - the Edge Function's service_role client only ever +-- calls auth.admin.createUser; workspace-membership authorization always +-- happens here, in Postgres, as the calling user. + +create or replace function public.sun_employee_prepare_v28(p_workspace uuid, p_email text, p_display_name text DEFAULT ''::text, p_role text DEFAULT 'manager'::text) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public', 'auth' AS $function$ declare v_email text:=lower(trim(coalesce(p_email,''))); v_name text:=trim(coalesce(p_display_name,'')); v_role text:=lower(coalesce(p_role,'manager')); v_user uuid; v_member public.sun_workspace_members%rowtype; v_max integer; v_count integer; begin if auth.uid() is null then raise exception 'Сначала войдите в Caterium'; end if; if not public.caterium_is_workspace_owner(p_workspace) and not public.sun_is_platform_admin() then raise exception 'Только владелец компании может добавлять сотрудников'; end if; if v_email='' or v_email !~* '^[^[:space:]@]+@[^[:space:]@]+\.[^[:space:]@]+$' then raise exception 'Введите корректный email сотрудника'; end if; if v_role not in ('manager','kitchen','courier','viewer') then raise exception 'Сотруднику нельзя назначить роль владельца'; end if; if v_name='' then v_name:=split_part(v_email,'@',1); end if; if public.sun_workspace_access_mode_internal_v28(p_workspace)<>'full' then raise exception 'Подписка компании не позволяет добавлять сотрудников'; end if; if not public.sun_workspace_feature_internal_v28(p_workspace,'users_manage') then raise exception 'Добавление сотрудников недоступно на текущем тарифе'; end if; select p.max_members into v_max from public.sun_workspace_subscriptions s join public.sun_plans p on p.id=s.plan_id where s.workspace_id=p_workspace; select count(*)::int into v_count from public.sun_workspace_members where workspace_id=p_workspace and is_active=true; select u.id into v_user from auth.users u where lower(coalesce(u.email,''))=v_email order by u.created_at asc limit 1; if v_user is not null then select * into v_member from public.sun_workspace_members where workspace_id=p_workspace and user_id=v_user limit 1; if found and v_member.is_active then return jsonb_build_object('status','already_member','user_id',v_user,'email',v_email,'display_name',coalesce(nullif(v_member.display_name,''),v_name),'role',v_member.role); end if; end if; if v_max is not null and v_count>=v_max then raise exception 'Достигнут лимит пользователей тарифа (%)',v_max; end if; return jsonb_build_object('status',case when v_user is null then 'new' else 'existing' end,'user_id',v_user,'email',v_email,'display_name',v_name,'role',v_role,'max_members',v_max,'active_members',v_count); end; $function$; + +create or replace function public.sun_employee_finalize_v28(p_workspace uuid, p_user_id uuid, p_display_name text DEFAULT ''::text, p_role text DEFAULT 'manager'::text) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public', 'auth' AS $function$ declare v_name text:=trim(coalesce(p_display_name,'')); v_role text:=lower(coalesce(p_role,'manager')); v_email text; v_max integer; v_count integer; v_already boolean:=false; begin if auth.uid() is null then raise exception 'Сначала войдите в Caterium'; end if; if not public.caterium_is_workspace_owner(p_workspace) and not public.sun_is_platform_admin() then raise exception 'Только владелец компании может добавлять сотрудников'; end if; if public.sun_workspace_access_mode_internal_v28(p_workspace)<>'full' then raise exception 'Подписка компании не позволяет добавлять сотрудников'; end if; if not public.sun_workspace_feature_internal_v28(p_workspace,'users_manage') then raise exception 'Добавление сотрудников недоступно на текущем тарифе'; end if; if v_role not in ('manager','kitchen','courier','viewer') then raise exception 'Сотруднику нельзя назначить роль владельца'; end if; select lower(email) into v_email from auth.users where id=p_user_id; if v_email is null then raise exception 'Аккаунт сотрудника не найден'; end if; if v_name='' then v_name:=split_part(v_email,'@',1); end if; select exists(select 1 from public.sun_workspace_members where workspace_id=p_workspace and user_id=p_user_id and is_active=true) into v_already; select p.max_members into v_max from public.sun_workspace_subscriptions s join public.sun_plans p on p.id=s.plan_id where s.workspace_id=p_workspace; select count(*)::int into v_count from public.sun_workspace_members where workspace_id=p_workspace and is_active=true; if not v_already and v_max is not null and v_count>=v_max then raise exception 'Достигнут лимит пользователей тарифа (%)',v_max; end if; insert into public.sun_workspace_members(workspace_id,user_id,role,display_name,is_active,permissions,updated_at) values(p_workspace,p_user_id,v_role,left(v_name,120),true,public.sun_role_default_permissions(v_role)||jsonb_build_object('users.manage',false),now()) on conflict(workspace_id,user_id) do update set role=excluded.role,display_name=excluded.display_name,is_active=true,permissions=excluded.permissions,updated_at=now(); delete from public.sun_workspace_invites where workspace_id=p_workspace and used_at is null and lower(coalesce(email,''))=v_email; return jsonb_build_object('status',case when v_already then 'updated' else 'added' end,'user_id',p_user_id,'email',v_email,'display_name',v_name,'role',v_role); end; $function$; + + +-- Source: ops/sql/SUPABASE-CHAT-V29.sql +create table if not exists public.sun_chat_threads ( + id uuid primary key default gen_random_uuid(), + workspace_id uuid not null references public.sun_workspaces(id) on delete cascade, + kind text not null check (kind in ('company','direct','order')), + title text, + order_id text, + direct_key text, + created_by uuid references auth.users(id) on delete set null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + last_message_at timestamptz +); +create unique index if not exists sun_chat_threads_company_uq on public.sun_chat_threads(workspace_id) where kind='company'; +create unique index if not exists sun_chat_threads_order_uq on public.sun_chat_threads(workspace_id,order_id) where kind='order'; +create unique index if not exists sun_chat_threads_direct_uq on public.sun_chat_threads(workspace_id,direct_key) where kind='direct'; +create index if not exists sun_chat_threads_workspace_idx on public.sun_chat_threads(workspace_id,coalesce(last_message_at,created_at) desc); + +create table if not exists public.sun_chat_participants ( + thread_id uuid not null references public.sun_chat_threads(id) on delete cascade, + workspace_id uuid not null references public.sun_workspaces(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + created_at timestamptz not null default now(), + primary key(thread_id,user_id) +); +create index if not exists sun_chat_participants_user_idx on public.sun_chat_participants(user_id,workspace_id); + +create table if not exists public.sun_chat_messages ( + id uuid primary key default gen_random_uuid(), + workspace_id uuid not null references public.sun_workspaces(id) on delete cascade, + thread_id uuid not null references public.sun_chat_threads(id) on delete cascade, + sender_user_id uuid not null references auth.users(id) on delete cascade, + body text not null default '', + attachments jsonb not null default '[]'::jsonb, + created_at timestamptz not null default now(), + edited_at timestamptz, + deleted_at timestamptz +); +create index if not exists sun_chat_messages_thread_idx on public.sun_chat_messages(thread_id,created_at desc); +create index if not exists sun_chat_messages_workspace_idx on public.sun_chat_messages(workspace_id,created_at desc); + +create table if not exists public.sun_chat_reads ( + thread_id uuid not null references public.sun_chat_threads(id) on delete cascade, + workspace_id uuid not null references public.sun_workspaces(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + last_read_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + primary key(thread_id,user_id) +); +create index if not exists sun_chat_reads_user_idx on public.sun_chat_reads(user_id,workspace_id); + +alter table public.sun_chat_threads enable row level security; +alter table public.sun_chat_participants enable row level security; +alter table public.sun_chat_messages enable row level security; +alter table public.sun_chat_reads enable row level security; + +create or replace function public.sun_chat_is_member_v29(p_workspace uuid,p_user uuid default null) +returns boolean +language sql +stable +security definer +set search_path='public','auth' +as $$ + select exists( + select 1 from public.sun_workspace_members m + where m.workspace_id=p_workspace + and m.user_id=coalesce(p_user,auth.uid()) + and m.is_active=true + ) +$$; + +create or replace function public.sun_chat_can_access_thread_as_v29(p_thread uuid,p_user uuid) +returns boolean +language plpgsql +stable +security definer +set search_path='public','auth' +as $$ +declare v public.sun_chat_threads%rowtype; +begin + if p_user is null then return false; end if; + select * into v from public.sun_chat_threads where id=p_thread; + if not found then return false; end if; + if not public.sun_chat_is_member_v29(v.workspace_id,p_user) then return false; end if; + if v.kind='direct' then + return exists(select 1 from public.sun_chat_participants p where p.thread_id=v.id and p.user_id=p_user); + end if; + return true; +end; +$$; + +create or replace function public.sun_chat_can_access_thread_v29(p_thread uuid) +returns boolean +language sql +stable +security definer +set search_path='public','auth' +as $$ select public.sun_chat_can_access_thread_as_v29(p_thread,auth.uid()) $$; + +create or replace function public.sun_chat_realtime_topic_access_v29(p_topic text) +returns boolean +language plpgsql +stable +security definer +set search_path='public','auth' +as $$ +declare v_id uuid; +begin + if p_topic like 'sun-chat-workspace:%' then + begin v_id:=substring(p_topic from length('sun-chat-workspace:')+1)::uuid; exception when others then return false; end; + return public.sun_chat_is_member_v29(v_id,auth.uid()); + elsif p_topic like 'sun-chat-thread:%' then + begin v_id:=substring(p_topic from length('sun-chat-thread:')+1)::uuid; exception when others then return false; end; + return public.sun_chat_can_access_thread_v29(v_id); + end if; + return false; +end; +$$; + +create or replace function public.sun_chat_storage_access_v29(p_name text) +returns boolean +language plpgsql +stable +security definer +set search_path='public','auth','storage' +as $$ +declare parts text[]; v_ws uuid; v_thread uuid; v_thread_ws uuid; +begin + parts:=storage.foldername(p_name); + if array_length(parts,1)<2 then return false; end if; + begin v_ws:=parts[1]::uuid; v_thread:=parts[2]::uuid; exception when others then return false; end; + select workspace_id into v_thread_ws from public.sun_chat_threads where id=v_thread; + if v_thread_ws is null or v_thread_ws<>v_ws then return false; end if; + return public.sun_chat_can_access_thread_as_v29(v_thread,auth.uid()); +end; +$$; + +-- Read-only table access for authenticated users; writes go through RPCs. +revoke all on public.sun_chat_threads,public.sun_chat_participants,public.sun_chat_messages,public.sun_chat_reads from anon,authenticated; +grant select on public.sun_chat_threads,public.sun_chat_participants,public.sun_chat_messages,public.sun_chat_reads to authenticated; + +-- RLS read policies. +drop policy if exists sun_chat_threads_read_v29 on public.sun_chat_threads; +create policy sun_chat_threads_read_v29 on public.sun_chat_threads for select to authenticated using (public.sun_chat_can_access_thread_v29(id)); +drop policy if exists sun_chat_participants_read_v29 on public.sun_chat_participants; +create policy sun_chat_participants_read_v29 on public.sun_chat_participants for select to authenticated using (public.sun_chat_can_access_thread_v29(thread_id)); +drop policy if exists sun_chat_messages_read_v29 on public.sun_chat_messages; +create policy sun_chat_messages_read_v29 on public.sun_chat_messages for select to authenticated using (public.sun_chat_can_access_thread_v29(thread_id)); +drop policy if exists sun_chat_reads_read_v29 on public.sun_chat_reads; +create policy sun_chat_reads_read_v29 on public.sun_chat_reads for select to authenticated using (user_id=auth.uid() and public.sun_chat_can_access_thread_v29(thread_id)); + +create or replace function public.sun_chat_get_company_thread_v29(p_workspace uuid) +returns uuid +language plpgsql +security definer +set search_path='public','auth' +as $$ +declare v_id uuid; +begin + if not public.sun_chat_is_member_v29(p_workspace,auth.uid()) then raise exception 'Нет доступа к чату компании'; end if; + select id into v_id from public.sun_chat_threads where workspace_id=p_workspace and kind='company' limit 1; + if v_id is null then + insert into public.sun_chat_threads(workspace_id,kind,title,created_by) values(p_workspace,'company','Общий чат',auth.uid()) + on conflict (workspace_id) where kind='company' do update set updated_at=excluded.updated_at returning id into v_id; + end if; + insert into public.sun_chat_reads(thread_id,workspace_id,user_id,last_read_at,updated_at) + values(v_id,p_workspace,auth.uid(),'epoch'::timestamptz,now()) on conflict(thread_id,user_id) do nothing; + return v_id; +end; +$$; + +create or replace function public.sun_chat_get_order_thread_v29(p_workspace uuid,p_order_id text) +returns uuid +language plpgsql +security definer +set search_path='public','auth' +as $$ +declare v_id uuid; v_order text:=trim(coalesce(p_order_id,'')); +begin + if not public.sun_chat_is_member_v29(p_workspace,auth.uid()) then raise exception 'Нет доступа к обсуждениям заказов'; end if; + if v_order='' then raise exception 'Номер заказа не указан'; end if; + select id into v_id from public.sun_chat_threads where workspace_id=p_workspace and kind='order' and order_id=v_order limit 1; + if v_id is null then + insert into public.sun_chat_threads(workspace_id,kind,title,order_id,created_by) + values(p_workspace,'order','Заказ № '||v_order,v_order,auth.uid()) + on conflict (workspace_id,order_id) where kind='order' do update set updated_at=excluded.updated_at returning id into v_id; + end if; + insert into public.sun_chat_reads(thread_id,workspace_id,user_id,last_read_at,updated_at) + values(v_id,p_workspace,auth.uid(),'epoch'::timestamptz,now()) on conflict(thread_id,user_id) do nothing; + return v_id; +end; +$$; + +create or replace function public.sun_chat_get_direct_thread_v29(p_workspace uuid,p_other_user uuid) +returns uuid +language plpgsql +security definer +set search_path='public','auth' +as $$ +declare v_me uuid:=auth.uid(); v_key text; v_id uuid; +begin + if v_me is null or not public.sun_chat_is_member_v29(p_workspace,v_me) then raise exception 'Нет доступа к чату компании'; end if; + if p_other_user is null or p_other_user=v_me then raise exception 'Выберите другого сотрудника'; end if; + if not public.sun_chat_is_member_v29(p_workspace,p_other_user) then raise exception 'Сотрудник больше не состоит в компании'; end if; + v_key:=least(v_me::text,p_other_user::text)||':'||greatest(v_me::text,p_other_user::text); + select id into v_id from public.sun_chat_threads where workspace_id=p_workspace and kind='direct' and direct_key=v_key limit 1; + if v_id is null then + insert into public.sun_chat_threads(workspace_id,kind,direct_key,created_by) + values(p_workspace,'direct',v_key,v_me) + on conflict (workspace_id,direct_key) where kind='direct' do update set updated_at=excluded.updated_at returning id into v_id; + end if; + insert into public.sun_chat_participants(thread_id,workspace_id,user_id) values(v_id,p_workspace,v_me) on conflict do nothing; + insert into public.sun_chat_participants(thread_id,workspace_id,user_id) values(v_id,p_workspace,p_other_user) on conflict do nothing; + insert into public.sun_chat_reads(thread_id,workspace_id,user_id,last_read_at,updated_at) + values(v_id,p_workspace,v_me,'epoch'::timestamptz,now()) on conflict(thread_id,user_id) do nothing; + return v_id; +end; +$$; + +create or replace function public.sun_chat_list_members_v29(p_workspace uuid) +returns table(user_id uuid,display_name text,email text,role text) +language plpgsql +stable +security definer +set search_path='public','auth' +as $$ +begin + if not public.sun_chat_is_member_v29(p_workspace,auth.uid()) then raise exception 'Нет доступа к сотрудникам компании'; end if; + return query select m.user_id,coalesce(nullif(m.display_name,''),split_part(coalesce(u.email,''),'@',1)),u.email::text,m.role + from public.sun_workspace_members m join auth.users u on u.id=m.user_id + where m.workspace_id=p_workspace and m.is_active=true + order by (m.user_id=auth.uid()) desc,coalesce(nullif(m.display_name,''),u.email::text); +end; +$$; + +create or replace function public.sun_chat_list_threads_v29(p_workspace uuid) +returns table(thread_id uuid,kind text,order_id text,title text,other_user_id uuid,last_message text,last_message_at timestamptz,unread_count bigint) +language plpgsql +stable +security definer +set search_path='public','auth' +as $$ +begin + if not public.sun_chat_is_member_v29(p_workspace,auth.uid()) then raise exception 'Нет доступа к чатам компании'; end if; + return query + select t.id,t.kind,t.order_id, + case when t.kind='direct' then coalesce(nullif(om.display_name,''),split_part(coalesce(ou.email,''),'@',1),'Сотрудник') else coalesce(t.title,case when t.kind='company' then 'Общий чат' else 'Обсуждение' end) end, + case when t.kind='direct' then op.user_id else null end, + case when lm.id is null then '' when nullif(trim(lm.body),'') is not null then left(lm.body,110) when jsonb_array_length(coalesce(lm.attachments,'[]'::jsonb))>0 then 'Вложение' else '' end, + lm.created_at, + (select count(*) from public.sun_chat_messages um where um.thread_id=t.id and um.deleted_at is null and um.sender_user_id<>auth.uid() and um.created_at>coalesce(r.last_read_at,'epoch'::timestamptz)) + from public.sun_chat_threads t + left join public.sun_chat_reads r on r.thread_id=t.id and r.user_id=auth.uid() + left join lateral (select m.* from public.sun_chat_messages m where m.thread_id=t.id and m.deleted_at is null order by m.created_at desc limit 1) lm on true + left join lateral (select p.user_id from public.sun_chat_participants p where p.thread_id=t.id and p.user_id<>auth.uid() limit 1) op on t.kind='direct' + left join public.sun_workspace_members om on om.workspace_id=t.workspace_id and om.user_id=op.user_id + left join auth.users ou on ou.id=op.user_id + where t.workspace_id=p_workspace and public.sun_chat_can_access_thread_as_v29(t.id,auth.uid()) + order by case t.kind when 'company' then 0 when 'direct' then 1 else 2 end,coalesce(t.last_message_at,t.created_at) desc; +end; +$$; + +create or replace function public.sun_chat_list_messages_v29(p_thread uuid,p_limit integer default 100,p_before timestamptz default null) +returns table(message_id uuid,sender_user_id uuid,sender_name text,sender_email text,body text,attachments jsonb,created_at timestamptz,read_by_count bigint) +language plpgsql +stable +security definer +set search_path='public','auth' +as $$ +begin + if not public.sun_chat_can_access_thread_as_v29(p_thread,auth.uid()) then raise exception 'Нет доступа к переписке'; end if; + return query + with recent as ( + select m.* from public.sun_chat_messages m + where m.thread_id=p_thread and m.deleted_at is null and (p_before is null or m.created_atm.sender_user_id and rr.last_read_at>=m.created_at) + from recent m + left join public.sun_workspace_members sm on sm.workspace_id=m.workspace_id and sm.user_id=m.sender_user_id + left join auth.users su on su.id=m.sender_user_id + order by m.created_at asc; +end; +$$; + +create or replace function public.sun_chat_send_message_v29(p_thread uuid,p_body text default '',p_attachments jsonb default '[]'::jsonb) +returns uuid +language plpgsql +security definer +set search_path='public','auth','realtime' +as $$ +declare v public.sun_chat_threads%rowtype; v_id uuid; v_body text:=trim(coalesce(p_body,'')); v_att jsonb:=coalesce(p_attachments,'[]'::jsonb); +begin + if not public.sun_chat_can_access_thread_as_v29(p_thread,auth.uid()) then raise exception 'Нет доступа к переписке'; end if; + select * into v from public.sun_chat_threads where id=p_thread; + if length(v_body)>4000 then raise exception 'Сообщение слишком длинное'; end if; + if jsonb_typeof(v_att)<>'array' then raise exception 'Некорректные вложения'; end if; + if jsonb_array_length(v_att)>5 then raise exception 'Можно отправить не более 5 файлов за раз'; end if; + if v_body='' and jsonb_array_length(v_att)=0 then raise exception 'Введите сообщение или добавьте файл'; end if; + insert into public.sun_chat_messages(workspace_id,thread_id,sender_user_id,body,attachments) + values(v.workspace_id,v.id,auth.uid(),v_body,v_att) returning id into v_id; + update public.sun_chat_threads set last_message_at=now(),updated_at=now() where id=v.id; + insert into public.sun_chat_reads(thread_id,workspace_id,user_id,last_read_at,updated_at) + values(v.id,v.workspace_id,auth.uid(),now(),now()) + on conflict(thread_id,user_id) do update set last_read_at=excluded.last_read_at,updated_at=now(); + perform realtime.send('{}'::jsonb,'chat_changed','sun-chat-workspace:'||v.workspace_id::text,true); + perform realtime.send(jsonb_build_object('message_id',v_id),'message','sun-chat-thread:'||v.id::text,true); + return v_id; +end; +$$; + +create or replace function public.sun_chat_mark_read_v29(p_thread uuid) +returns void +language plpgsql +security definer +set search_path='public','auth','realtime' +as $$ +declare v_ws uuid; v_old timestamptz; v_changed boolean:=false; +begin + if not public.sun_chat_can_access_thread_as_v29(p_thread,auth.uid()) then raise exception 'Нет доступа к переписке'; end if; + select workspace_id into v_ws from public.sun_chat_threads where id=p_thread; + select last_read_at into v_old from public.sun_chat_reads where thread_id=p_thread and user_id=auth.uid(); + select exists( + select 1 from public.sun_chat_messages m + where m.thread_id=p_thread and m.deleted_at is null and m.sender_user_id<>auth.uid() + and m.created_at>coalesce(v_old,'epoch'::timestamptz) + ) into v_changed; + insert into public.sun_chat_reads(thread_id,workspace_id,user_id,last_read_at,updated_at) + values(p_thread,v_ws,auth.uid(),now(),now()) + on conflict(thread_id,user_id) do update set last_read_at=excluded.last_read_at,updated_at=now(); + if v_changed then + perform realtime.send(jsonb_build_object('thread_id',p_thread),'read','sun-chat-thread:'||p_thread::text,true); + perform realtime.send('{}'::jsonb,'chat_changed','sun-chat-workspace:'||v_ws::text,true); + end if; +end; +$$; + +create or replace function public.sun_chat_unread_total_v29(p_workspace uuid) +returns bigint +language plpgsql +stable +security definer +set search_path='public','auth' +as $$ +declare v_total bigint; +begin + if not public.sun_chat_is_member_v29(p_workspace,auth.uid()) then return 0; end if; + select count(*) into v_total + from public.sun_chat_messages m + join public.sun_chat_threads t on t.id=m.thread_id + left join public.sun_chat_reads r on r.thread_id=t.id and r.user_id=auth.uid() + where t.workspace_id=p_workspace and m.deleted_at is null and m.sender_user_id<>auth.uid() + and public.sun_chat_can_access_thread_as_v29(t.id,auth.uid()) + and m.created_at>coalesce(r.last_read_at,'epoch'::timestamptz); + return coalesce(v_total,0); +end; +$$; + +-- Private Storage bucket for chat files. +insert into storage.buckets(id,name,public,file_size_limit) +values('sun-chat','sun-chat',false,15728640) +on conflict(id) do update set public=false,file_size_limit=15728640; + +drop policy if exists sun_chat_storage_read_v29 on storage.objects; +create policy sun_chat_storage_read_v29 on storage.objects for select to authenticated +using(bucket_id='sun-chat' and public.sun_chat_storage_access_v29(name)); +drop policy if exists sun_chat_storage_insert_v29 on storage.objects; +create policy sun_chat_storage_insert_v29 on storage.objects for insert to authenticated +with check(bucket_id='sun-chat' and public.sun_chat_storage_access_v29(name)); + +-- Realtime private-channel authorization for chat workspace/thread topics. +drop policy if exists sun_chat_realtime_read_v29 on realtime.messages; +create policy sun_chat_realtime_read_v29 on realtime.messages for select to authenticated +using(realtime.messages.extension in ('broadcast','presence') and public.sun_chat_realtime_topic_access_v29((select realtime.topic()))); +drop policy if exists sun_chat_realtime_write_v29 on realtime.messages; +create policy sun_chat_realtime_write_v29 on realtime.messages for insert to authenticated +with check(realtime.messages.extension in ('broadcast','presence') and public.sun_chat_realtime_topic_access_v29((select realtime.topic()))); + +revoke all on function public.sun_chat_is_member_v29(uuid,uuid) from public,anon; +revoke all on function public.sun_chat_can_access_thread_as_v29(uuid,uuid) from public,anon; +revoke all on function public.sun_chat_can_access_thread_v29(uuid) from public,anon; +revoke all on function public.sun_chat_realtime_topic_access_v29(text) from public,anon; +revoke all on function public.sun_chat_storage_access_v29(text) from public,anon; +revoke all on function public.sun_chat_get_company_thread_v29(uuid) from public,anon; +revoke all on function public.sun_chat_get_order_thread_v29(uuid,text) from public,anon; +revoke all on function public.sun_chat_get_direct_thread_v29(uuid,uuid) from public,anon; +revoke all on function public.sun_chat_list_members_v29(uuid) from public,anon; +revoke all on function public.sun_chat_list_threads_v29(uuid) from public,anon; +revoke all on function public.sun_chat_list_messages_v29(uuid,integer,timestamptz) from public,anon; +revoke all on function public.sun_chat_send_message_v29(uuid,text,jsonb) from public,anon; +revoke all on function public.sun_chat_mark_read_v29(uuid) from public,anon; +revoke all on function public.sun_chat_unread_total_v29(uuid) from public,anon; + +grant execute on function public.sun_chat_realtime_topic_access_v29(text) to authenticated; +grant execute on function public.sun_chat_storage_access_v29(text) to authenticated; +grant execute on function public.sun_chat_get_company_thread_v29(uuid) to authenticated; +grant execute on function public.sun_chat_get_order_thread_v29(uuid,text) to authenticated; +grant execute on function public.sun_chat_get_direct_thread_v29(uuid,uuid) to authenticated; +grant execute on function public.sun_chat_list_members_v29(uuid) to authenticated; +grant execute on function public.sun_chat_list_threads_v29(uuid) to authenticated; +grant execute on function public.sun_chat_list_messages_v29(uuid,integer,timestamptz) to authenticated; +grant execute on function public.sun_chat_send_message_v29(uuid,text,jsonb) to authenticated; +grant execute on function public.sun_chat_mark_read_v29(uuid) to authenticated; +grant execute on function public.sun_chat_unread_total_v29(uuid) to authenticated; + + +-- Source: ops/sql/SUPABASE-ADMIN-RIGHTS-V30.sql +-- Caterium v17.5.30 - granular administrator permissions and correct last-admin checks. + +create or replace function public.sun_has_permission(p_workspace uuid, p_permission text) +returns boolean +language plpgsql +stable +security definer +set search_path='public' +as $$ +declare + v_role text; + v_permissions jsonb; + v_active boolean; +begin + select role,permissions,is_active + into v_role,v_permissions,v_active + from public.sun_workspace_members + where workspace_id=p_workspace and user_id=auth.uid() + limit 1; + + if not coalesce(v_active,false) then return false; end if; + + -- Explicit member permissions override the role template for every role, + -- including administrators. Missing keys fall back to the role defaults. + if coalesce(v_permissions,'{}'::jsonb) ? p_permission then + return coalesce((v_permissions->>p_permission)::boolean,false); + end if; + return coalesce((public.sun_role_default_permissions(v_role)->>p_permission)::boolean,false); +end; +$$; + +create or replace function public.sun_admin_update_member( + p_workspace uuid, + p_user uuid, + p_display_name text, + p_role text, + p_is_active boolean, + p_permissions jsonb +) +returns void +language plpgsql +security definer +set search_path='public' +as $$ +declare + v_old_role text; + v_old_active boolean; + v_role text:=lower(coalesce(p_role,'')); + v_other_admins integer:=0; + v_other_managing_admins integer:=0; + v_target_will_manage boolean:=false; + v_max integer; + v_active_count integer; +begin + -- Serialize membership administration inside one workspace so two admins + -- cannot simultaneously remove/demote the last administrators. + perform pg_advisory_xact_lock(hashtext(p_workspace::text)); + + if public.sun_subscription_access_mode(p_workspace)<>'full' then raise exception 'Подписка не позволяет изменять пользователей'; end if; + if not public.sun_workspace_has_feature(p_workspace,'users_manage') then raise exception 'Управление сотрудниками недоступно на текущем тарифе'; end if; + if not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Недостаточно прав для управления сотрудниками'; end if; + if v_role not in ('admin','manager','kitchen','courier','viewer') then raise exception 'Некорректная роль'; end if; + if p_permissions is null or jsonb_typeof(p_permissions)<>'object' then raise exception 'Некорректные права пользователя'; end if; + + select role,is_active into v_old_role,v_old_active + from public.sun_workspace_members + where workspace_id=p_workspace and user_id=p_user + for update; + if not found then raise exception 'Пользователь не найден'; end if; + + select count(*)::int into v_other_admins + from public.sun_workspace_members + where workspace_id=p_workspace + and user_id<>p_user + and role='admin' + and is_active=true; + + if v_old_role='admin' and coalesce(v_old_active,false) + and (v_role<>'admin' or not coalesce(p_is_active,false)) + and v_other_admins=0 then + raise exception 'Нельзя отключить или понизить последнего активного администратора'; + end if; + + select count(*)::int into v_other_managing_admins + from public.sun_workspace_members + where workspace_id=p_workspace + and user_id<>p_user + and role='admin' + and is_active=true + and coalesce( + case when coalesce(permissions,'{}'::jsonb) ? 'users.manage' + then (permissions->>'users.manage')::boolean + else null end, + true + )=true; + + v_target_will_manage := v_role='admin' + and coalesce(p_is_active,false) + and coalesce( + case when p_permissions ? 'users.manage' + then (p_permissions->>'users.manage')::boolean + else null end, + true + ); + + if not v_target_will_manage and v_other_managing_admins=0 then + raise exception 'У хотя бы одного активного администратора должно оставаться право «Пользователи и права»'; + end if; + + if coalesce(p_is_active,false) and not coalesce(v_old_active,false) then + select p.max_members into v_max + from public.sun_workspace_subscriptions s + join public.sun_plans p on p.id=s.plan_id + where s.workspace_id=p_workspace; + select count(*)::int into v_active_count + from public.sun_workspace_members + where workspace_id=p_workspace and is_active=true; + if v_max is not null and v_active_count>=v_max then + raise exception 'Достигнут лимит сотрудников тарифа (%).',v_max; + end if; + end if; + + update public.sun_workspace_members + set display_name=nullif(trim(coalesce(p_display_name,'')),''), + role=v_role, + is_active=coalesce(p_is_active,false), + permissions=p_permissions, + updated_at=now() + where workspace_id=p_workspace and user_id=p_user; +end; +$$; + +create or replace function public.sun_admin_remove_member(p_workspace uuid,p_user uuid) +returns void +language plpgsql +security definer +set search_path='public' +as $$ +declare + v_role text; + v_active boolean; + v_other_admins integer:=0; + v_other_managing_admins integer:=0; +begin + perform pg_advisory_xact_lock(hashtext(p_workspace::text)); + + if public.sun_subscription_access_mode(p_workspace)<>'full' then raise exception 'Подписка не позволяет изменять пользователей'; end if; + if not public.sun_workspace_has_feature(p_workspace,'users_manage') then raise exception 'Управление сотрудниками недоступно на текущем тарифе'; end if; + if not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Недостаточно прав для управления сотрудниками'; end if; + + select role,is_active into v_role,v_active + from public.sun_workspace_members + where workspace_id=p_workspace and user_id=p_user + for update; + if not found then return; end if; + + if v_role='admin' and coalesce(v_active,false) then + select count(*)::int into v_other_admins + from public.sun_workspace_members + where workspace_id=p_workspace and user_id<>p_user and role='admin' and is_active=true; + if v_other_admins=0 then raise exception 'Нельзя удалить последнего активного администратора'; end if; + + select count(*)::int into v_other_managing_admins + from public.sun_workspace_members + where workspace_id=p_workspace + and user_id<>p_user + and role='admin' + and is_active=true + and coalesce( + case when coalesce(permissions,'{}'::jsonb) ? 'users.manage' + then (permissions->>'users.manage')::boolean + else null end, + true + )=true; + if v_other_managing_admins=0 then + raise exception 'Нельзя удалить администратора: после удаления никто не сможет управлять пользователями'; + end if; + end if; + + delete from public.sun_workspace_members + where workspace_id=p_workspace and user_id=p_user; +end; +$$; + +revoke all on function public.sun_has_permission(uuid,text) from public,anon; +revoke all on function public.sun_admin_update_member(uuid,uuid,text,text,boolean,jsonb) from public,anon; +revoke all on function public.sun_admin_remove_member(uuid,uuid) from public,anon; +grant execute on function public.sun_has_permission(uuid,text) to authenticated; +grant execute on function public.sun_admin_update_member(uuid,uuid,text,text,boolean,jsonb) to authenticated; +grant execute on function public.sun_admin_remove_member(uuid,uuid) to authenticated; + + +-- Source: ops/sql/SUPABASE-V17.6.1-CHAT-STORAGE-GUARD.sql +-- Caterium v17.6.1: server memory counters for Developer Console +create or replace function public.sun_platform_dashboard() +returns jsonb +language plpgsql +stable +security definer +set search_path='public','auth' +as $$ +declare + v_companies bigint; v_accounts bigint; v_admins bigint; v_active bigint; v_trial bigint; + v_locked bigint; v_errors bigint; v_backups bigint; v_members bigint; + v_last_state timestamptz; v_last_backup timestamptz; + v_database_bytes bigint := 0; v_storage_bytes bigint := 0; v_storage_objects bigint := 0; + v_storage_buckets jsonb := '{}'::jsonb; +begin + perform public.sun_require_platform_admin_aal2(); + select count(*) into v_companies from public.sun_workspaces; + select count(*) into v_accounts from auth.users; + select count(*) into v_admins from public.sun_platform_admins; + select count(*) into v_members from public.sun_workspace_members where is_active=true; + select count(*) into v_active from public.sun_workspace_subscriptions where status='active' and coalesce(current_period_end,'infinity'::timestamptz)>now(); + select count(*) into v_trial from public.sun_workspace_subscriptions where status='trialing' and coalesce(trial_ends_at,'infinity'::timestamptz)>now(); + select count(*) into v_locked from public.sun_workspaces w where public.sun_subscription_access_mode(w.id) in ('read_only','blocked'); + select count(*) into v_errors from public.sun_v17_error_events where created_at>now()-interval '24 hours'; + select count(*) into v_backups from public.sun_v17_backups where created_at>now()-interval '24 hours'; + select max(updated_at) into v_last_state from public.sun_app_state; + select max(created_at) into v_last_backup from public.sun_v17_backups; + v_database_bytes := pg_database_size(current_database()); + select count(*), coalesce(sum(case when coalesce(metadata->>'size','') ~ '^[0-9]+$' then (metadata->>'size')::bigint else 0 end),0) + into v_storage_objects, v_storage_bytes from storage.objects; + select coalesce(jsonb_object_agg(bucket_id,jsonb_build_object('objects',object_count,'bytes',bucket_bytes,'size',pg_size_pretty(bucket_bytes))),'{}'::jsonb) + into v_storage_buckets + from (select bucket_id,count(*)::bigint as object_count,coalesce(sum(case when coalesce(metadata->>'size','') ~ '^[0-9]+$' then (metadata->>'size')::bigint else 0 end),0)::bigint as bucket_bytes from storage.objects group by bucket_id) s; + return jsonb_build_object( + 'companies',v_companies,'accounts',v_accounts,'platform_admins',v_admins,'memberships',v_members, + 'active_subscriptions',v_active,'trials',v_trial,'restricted_companies',v_locked, + 'errors_24h',v_errors,'backups_24h',v_backups,'last_state_at',v_last_state,'last_backup_at',v_last_backup, + 'database_bytes',v_database_bytes,'database_size',pg_size_pretty(v_database_bytes), + 'storage_bytes',v_storage_bytes,'storage_size',pg_size_pretty(v_storage_bytes),'storage_objects',v_storage_objects,'storage_buckets',v_storage_buckets, + 'server_bytes',v_database_bytes+v_storage_bytes,'server_size',pg_size_pretty(v_database_bytes+v_storage_bytes), + 'postgres_version',current_setting('server_version')); +end; +$$; +revoke all on function public.sun_platform_dashboard() from public, anon; +grant execute on function public.sun_platform_dashboard() to authenticated; + + +-- Source: ops/sql/SUPABASE-V17.6.8-DEVELOPER-CONSOLE-UX.sql +-- Caterium v17.6.8 Developer Console UX +-- Persistent company numbers, safe company deletion and grouped production error journal. + +create sequence if not exists public.sun_workspace_company_number_seq; + +alter table public.sun_workspaces + add column if not exists company_number bigint; + +with numbered as ( + select id,row_number() over(order by created_at,id)::bigint as n + from public.sun_workspaces + where company_number is null +), existing as ( + select coalesce(max(company_number),0)::bigint as max_n from public.sun_workspaces +) +update public.sun_workspaces w +set company_number = numbered.n + existing.max_n +from numbered,existing +where w.id=numbered.id and w.company_number is null; + +select setval( + 'public.sun_workspace_company_number_seq', + greatest(1,coalesce((select max(company_number) from public.sun_workspaces),0)), + coalesce((select max(company_number) from public.sun_workspaces),0)>0 +); + +alter table public.sun_workspaces + alter column company_number set default nextval('public.sun_workspace_company_number_seq'); + +update public.sun_workspaces +set company_number=nextval('public.sun_workspace_company_number_seq') +where company_number is null; + +alter table public.sun_workspaces + alter column company_number set not null; + +create unique index if not exists sun_workspaces_company_number_uq + on public.sun_workspaces(company_number); + +create or replace function public.sun_dev_list_companies_v1768() +returns table( + workspace_id uuid, + workspace_name text, + company_number bigint, + created_at timestamptz, + plan_id text, + plan_name text, + status text, + access_mode text, + trial_ends_at timestamptz, + current_period_end timestamptz, + grace_until timestamptz, + member_count bigint, + max_members integer, + owner_email text, + pending_owner_email text +) +language plpgsql +stable +security definer +set search_path='public','auth' +as $$ +begin + perform public.sun_require_platform_admin_aal2(); + return query + select + w.id, + w.name, + w.company_number, + w.created_at, + s.plan_id, + p.name, + s.status, + public.sun_subscription_access_mode(w.id), + s.trial_ends_at, + s.current_period_end, + s.grace_until, + (select count(*) from public.sun_workspace_members m where m.workspace_id=w.id and m.is_active), + p.max_members, + (select u.email::text + from public.sun_workspace_members m + join auth.users u on u.id=m.user_id + where m.workspace_id=w.id and m.role='admin' and m.is_active + order by m.created_at asc + limit 1), + (select i.email + from public.caterium_company_owner_invites i + where i.workspace_id=w.id and i.used_at is null and i.expires_at>now() + order by i.created_at desc + limit 1) + from public.sun_workspaces w + left join public.sun_workspace_subscriptions s on s.workspace_id=w.id + left join public.sun_plans p on p.id=s.plan_id + order by w.company_number; +end; +$$; + +create or replace function public.sun_dev_delete_company_v1768( + p_workspace uuid, + p_confirm_name text +) returns jsonb +language plpgsql +security definer +set search_path='public','auth' +as $$ +declare + v_name text; + v_number bigint; + v_members bigint; + v_orders bigint; + v_clients bigint; + v_catalog bigint; +begin + perform public.sun_require_platform_admin_aal2(); + + select w.name,w.company_number + into v_name,v_number + from public.sun_workspaces w + where w.id=p_workspace + for update; + + if v_name is null then raise exception 'Workspace not found'; end if; + if trim(coalesce(p_confirm_name,'')) <> v_name then raise exception 'Company name confirmation mismatch'; end if; + + select count(*) into v_members from public.sun_workspace_members where workspace_id=p_workspace; + select count(*) into v_orders from public.sun_v17_orders where workspace_id=p_workspace; + select count(*) into v_clients from public.sun_v17_clients where workspace_id=p_workspace; + select count(*) into v_catalog from public.sun_v17_catalog_items where workspace_id=p_workspace; + + perform public.sun_platform_log_event( + 'company.delete', + p_workspace, + null, + jsonb_build_object( + 'company',v_name, + 'company_number',v_number, + 'members',v_members, + 'orders',v_orders, + 'clients',v_clients, + 'catalog_items',v_catalog, + 'auth_accounts_preserved',true + ) + ); + + delete from public.sun_workspaces where id=p_workspace; + + return jsonb_build_object( + 'deleted',true, + 'workspace_id',p_workspace, + 'workspace_name',v_name, + 'company_number',v_number, + 'memberships_removed',v_members, + 'auth_accounts_preserved',true + ); +end; +$$; + +create or replace function public.sun_dev_error_groups_v1768( + p_hours integer default 168, + p_limit integer default 100 +) returns table( + workspace_name text, + app_version text, + level text, + message text, + occurrences bigint, + first_seen timestamptz, + last_seen timestamptz, + sample_stack text, + source_url text +) +language plpgsql +stable +security definer +set search_path='public' +as $$ +declare + v_hours integer:=greatest(1,least(coalesce(p_hours,168),720)); + v_limit integer:=greatest(1,least(coalesce(p_limit,100),200)); +begin + perform public.sun_require_platform_admin_aal2(); + return query + with scoped as ( + select + e.*, + w.name as company_name, + nullif(e.context->>'url','') as event_url + from public.sun_v17_error_events e + left join public.sun_workspaces w on w.id=e.workspace_id + where e.created_at >= now()-make_interval(hours=>v_hours) + and coalesce(e.context->>'url','') !~* '^file:' + ), grouped as ( + select + company_name, + coalesce(app_version,'') as app_version, + coalesce(level,'error') as level, + coalesce(message,'Unknown error') as message, + count(*)::bigint as occurrences, + min(created_at) as first_seen, + max(created_at) as last_seen + from scoped + group by company_name,coalesce(app_version,''),coalesce(level,'error'),coalesce(message,'Unknown error') + ) + select + g.company_name, + nullif(g.app_version,''), + g.level, + g.message, + g.occurrences, + g.first_seen, + g.last_seen, + s.stack, + s.event_url + from grouped g + left join lateral ( + select x.stack,x.event_url + from scoped x + where x.company_name is not distinct from g.company_name + and coalesce(x.app_version,'')=g.app_version + and coalesce(x.level,'error')=g.level + and coalesce(x.message,'Unknown error')=g.message + order by x.created_at desc + limit 1 + ) s on true + order by g.last_seen desc + limit v_limit; +end; +$$; + +revoke all on function public.sun_dev_list_companies_v1768() from public,anon; +revoke all on function public.sun_dev_delete_company_v1768(uuid,text) from public,anon; +revoke all on function public.sun_dev_error_groups_v1768(integer,integer) from public,anon; + +grant execute on function public.sun_dev_list_companies_v1768() to authenticated; +grant execute on function public.sun_dev_delete_company_v1768(uuid,text) to authenticated; +grant execute on function public.sun_dev_error_groups_v1768(integer,integer) to authenticated; + + +-- Source: ops/sql/CATERIUM-OWNER-ACCOUNTS-V31.sql +-- Caterium v31: one company owner account, owner-only employee administration, +-- and a self-service profile for every authenticated user. + +create or replace function public.caterium_is_workspace_owner(p_workspace uuid) +returns boolean +language sql +stable +security definer +set search_path='public' +as $$ + select auth.uid() is not null + and exists( + select 1 + from public.sun_workspaces w + join public.sun_workspace_members m + on m.workspace_id=w.id + and m.user_id=auth.uid() + and m.is_active=true + where w.id=p_workspace + and w.created_by=auth.uid() + ); +$$; + +revoke all on function public.caterium_is_workspace_owner(uuid) from public,anon; +grant execute on function public.caterium_is_workspace_owner(uuid) to authenticated; + +create or replace function public.caterium_account_snapshot(p_workspace uuid) +returns jsonb +language plpgsql +stable +security definer +set search_path='public','auth' +as $$ +declare + v_user uuid:=auth.uid(); + v_row record; +begin + if v_user is null then raise exception 'Сначала войдите в Caterium'; end if; + + select w.id as workspace_id, + w.name as company_name, + w.company_number, + w.created_by, + m.display_name, + m.role, + m.is_active, + coalesce(m.permissions,'{}'::jsonb) as permissions, + u.email::text as email + into v_row + from public.sun_workspaces w + join public.sun_workspace_members m + on m.workspace_id=w.id + and m.user_id=v_user + join auth.users u on u.id=v_user + where w.id=p_workspace + and m.is_active=true + limit 1; + + if not found then raise exception 'Нет доступа к компании'; end if; + + return jsonb_build_object( + 'user_id',v_user, + 'email',v_row.email, + 'display_name',coalesce(nullif(v_row.display_name,''),split_part(coalesce(v_row.email,''),'@',1)), + 'role',v_row.role, + 'is_owner',(v_row.created_by=v_user), + 'workspace_id',v_row.workspace_id, + 'company_name',v_row.company_name, + 'company_number',v_row.company_number, + 'permissions',v_row.permissions + ); +end; +$$; + +revoke all on function public.caterium_account_snapshot(uuid) from public,anon; +grant execute on function public.caterium_account_snapshot(uuid) to authenticated; + +create or replace function public.caterium_update_my_name(p_display_name text) +returns text +language plpgsql +security definer +set search_path='public','auth' +as $$ +declare + v_user uuid:=auth.uid(); + v_name text:=trim(coalesce(p_display_name,'')); +begin + if v_user is null then raise exception 'Сначала войдите в Caterium'; end if; + if char_length(v_name)<1 then raise exception 'Введите имя'; end if; + if char_length(v_name)>120 then raise exception 'Имя слишком длинное'; end if; + + update public.sun_workspace_members + set display_name=v_name, + updated_at=now() + where user_id=v_user; + + update auth.users + set raw_user_meta_data=jsonb_set(coalesce(raw_user_meta_data,'{}'::jsonb),'{name}',to_jsonb(v_name),true) + where id=v_user; + + return v_name; +end; +$$; + +revoke all on function public.caterium_update_my_name(text) from public,anon; +grant execute on function public.caterium_update_my_name(text) to authenticated; + +-- Existing company creators are the only company administrators. +update public.sun_workspace_members m + set role='admin', + permissions=jsonb_set(coalesce(m.permissions,public.sun_role_default_permissions('admin')),'{users.manage}','true'::jsonb,true), + updated_at=now() + from public.sun_workspaces w + where w.id=m.workspace_id + and m.user_id=w.created_by; + +-- Any non-owner account is an employee account and can never manage users. +update public.sun_workspace_members m + set role=case when m.role='admin' then 'manager' else m.role end, + permissions=jsonb_set(coalesce(m.permissions,public.sun_role_default_permissions(case when m.role='admin' then 'manager' else m.role end)),'{users.manage}','false'::jsonb,true), + updated_at=now() + from public.sun_workspaces w + where w.id=m.workspace_id + and m.user_id<>w.created_by; + +-- No pending invitation may create a second company administrator. +delete from public.sun_workspace_invites where used_at is null and role='admin'; + +create or replace function public.sun_list_workspace_members(p_workspace uuid) +returns table(user_id uuid,email text,display_name text,role text,is_active boolean,permissions jsonb,created_at timestamptz,updated_at timestamptz) +language plpgsql +stable +security definer +set search_path='public','auth' +as $$ +begin + if not public.caterium_is_workspace_owner(p_workspace) and not public.sun_is_platform_admin() then + raise exception 'Только владелец компании может управлять пользователями'; + end if; + return query + select m.user_id,u.email::text,m.display_name,m.role,m.is_active, + coalesce(m.permissions,public.sun_role_default_permissions(m.role)),m.created_at,m.updated_at + from public.sun_workspace_members m + join auth.users u on u.id=m.user_id + join public.sun_workspaces w on w.id=m.workspace_id + where m.workspace_id=p_workspace + order by (m.user_id=w.created_by) desc,coalesce(m.display_name,u.email::text); +end; +$$; + +create or replace function public.sun_admin_update_member( + p_workspace uuid, + p_user uuid, + p_display_name text, + p_role text, + p_is_active boolean, + p_permissions jsonb +) +returns void +language plpgsql +security definer +set search_path='public' +as $$ +declare + v_role text:=lower(coalesce(p_role,'')); + v_owner uuid; + v_max integer; + v_active_count integer; + v_permissions jsonb; +begin + perform pg_advisory_xact_lock(hashtext(p_workspace::text)); + + if not public.caterium_is_workspace_owner(p_workspace) and not public.sun_is_platform_admin() then + raise exception 'Только владелец компании может управлять сотрудниками'; + end if; + if public.sun_subscription_access_mode(p_workspace)<>'full' then raise exception 'Подписка не позволяет изменять пользователей'; end if; + if not public.sun_workspace_has_feature(p_workspace,'users_manage') then raise exception 'Управление сотрудниками недоступно на текущем тарифе'; end if; + if v_role not in ('manager','kitchen','courier','viewer') then raise exception 'Сотруднику нельзя назначить роль владельца'; end if; + if p_permissions is null or jsonb_typeof(p_permissions)<>'object' then raise exception 'Некорректные права пользователя'; end if; + + select created_by into v_owner from public.sun_workspaces where id=p_workspace; + if v_owner is null then raise exception 'Компания не найдена'; end if; + if p_user=v_owner then raise exception 'Главный аккаунт компании нельзя изменить через управление сотрудниками'; end if; + if not exists(select 1 from public.sun_workspace_members where workspace_id=p_workspace and user_id=p_user) then raise exception 'Пользователь не найден'; end if; + + if coalesce(p_is_active,false) + and not coalesce((select is_active from public.sun_workspace_members where workspace_id=p_workspace and user_id=p_user),false) then + select p.max_members into v_max + from public.sun_workspace_subscriptions s + join public.sun_plans p on p.id=s.plan_id + where s.workspace_id=p_workspace; + select count(*)::int into v_active_count + from public.sun_workspace_members + where workspace_id=p_workspace and is_active=true; + if v_max is not null and v_active_count>=v_max then raise exception 'Достигнут лимит сотрудников тарифа (%).',v_max; end if; + end if; + + v_permissions:=coalesce(p_permissions,'{}'::jsonb)||jsonb_build_object('users.manage',false); + + update public.sun_workspace_members + set display_name=nullif(trim(coalesce(p_display_name,'')),''), + role=v_role, + is_active=coalesce(p_is_active,false), + permissions=v_permissions, + updated_at=now() + where workspace_id=p_workspace and user_id=p_user; +end; +$$; + +create or replace function public.sun_admin_remove_member(p_workspace uuid,p_user uuid) +returns void +language plpgsql +security definer +set search_path='public' +as $$ +declare + v_owner uuid; +begin + perform pg_advisory_xact_lock(hashtext(p_workspace::text)); + if not public.caterium_is_workspace_owner(p_workspace) and not public.sun_is_platform_admin() then + raise exception 'Только владелец компании может удалять сотрудников'; + end if; + if public.sun_subscription_access_mode(p_workspace)<>'full' then raise exception 'Подписка не позволяет изменять пользователей'; end if; + if not public.sun_workspace_has_feature(p_workspace,'users_manage') then raise exception 'Управление сотрудниками недоступно на текущем тарифе'; end if; + select created_by into v_owner from public.sun_workspaces where id=p_workspace; + if p_user=v_owner then raise exception 'Главный аккаунт компании удалить нельзя'; end if; + delete from public.sun_workspace_members where workspace_id=p_workspace and user_id=p_user; +end; +$$; + +create or replace function public.sun_employee_prepare_v28( + p_workspace uuid, + p_email text, + p_display_name text default '', + p_role text default 'manager' +) +returns jsonb +language plpgsql +security definer +set search_path='public','auth' +as $$ +declare + v_email text:=lower(trim(coalesce(p_email,''))); + v_name text:=trim(coalesce(p_display_name,'')); + v_role text:=lower(coalesce(p_role,'manager')); + v_user uuid; + v_member public.sun_workspace_members%rowtype; + v_max integer; + v_count integer; + v_mode text; +begin + if auth.uid() is null then raise exception 'Сначала войдите в Caterium'; end if; + if not public.caterium_is_workspace_owner(p_workspace) and not public.sun_is_platform_admin() then raise exception 'Только владелец компании может добавлять сотрудников'; end if; + if v_email='' or v_email !~* '^[^[:space:]@]+@[^[:space:]@]+\.[^[:space:]@]+$' then raise exception 'Введите корректный email сотрудника'; end if; + if v_role not in ('manager','kitchen','courier','viewer') then raise exception 'Сотруднику нельзя назначить роль владельца'; end if; + if v_name='' then v_name:=split_part(v_email,'@',1); end if; + + v_mode:=public.sun_workspace_access_mode_internal_v28(p_workspace); + if v_mode<>'full' then raise exception 'Подписка компании не позволяет добавлять сотрудников'; end if; + if not public.sun_workspace_feature_internal_v28(p_workspace,'users_manage') then raise exception 'Добавление сотрудников недоступно на текущем тарифе'; end if; + + select p.max_members into v_max + from public.sun_workspace_subscriptions s join public.sun_plans p on p.id=s.plan_id + where s.workspace_id=p_workspace; + + select count(*)::int into v_count from public.sun_workspace_members + where workspace_id=p_workspace and is_active=true; + + select u.id into v_user from auth.users u where lower(coalesce(u.email,''))=v_email order by u.created_at asc limit 1; + + if v_user is not null then + select * into v_member from public.sun_workspace_members + where workspace_id=p_workspace and user_id=v_user limit 1; + if found and v_member.is_active then + return jsonb_build_object('status','already_member','user_id',v_user,'email',v_email,'display_name',coalesce(nullif(v_member.display_name,''),v_name),'role',v_member.role); + end if; + end if; + + if v_max is not null and v_count>=v_max then raise exception 'Достигнут лимит пользователей тарифа (%)',v_max; end if; + + return jsonb_build_object( + 'status',case when v_user is null then 'new' else 'existing' end, + 'user_id',v_user, + 'email',v_email, + 'display_name',v_name, + 'role',v_role, + 'max_members',v_max, + 'active_members',v_count + ); +end; +$$; + +create or replace function public.sun_employee_finalize_v28( + p_workspace uuid, + p_user_id uuid, + p_display_name text default '', + p_role text default 'manager' +) +returns jsonb +language plpgsql +security definer +set search_path='public','auth' +as $$ +declare + v_name text:=trim(coalesce(p_display_name,'')); + v_role text:=lower(coalesce(p_role,'manager')); + v_email text; + v_max integer; + v_count integer; + v_already boolean:=false; +begin + if auth.uid() is null then raise exception 'Сначала войдите в Caterium'; end if; + if not public.caterium_is_workspace_owner(p_workspace) and not public.sun_is_platform_admin() then raise exception 'Только владелец компании может добавлять сотрудников'; end if; + if public.sun_workspace_access_mode_internal_v28(p_workspace)<>'full' then raise exception 'Подписка компании не позволяет добавлять сотрудников'; end if; + if not public.sun_workspace_feature_internal_v28(p_workspace,'users_manage') then raise exception 'Добавление сотрудников недоступно на текущем тарифе'; end if; + if v_role not in ('manager','kitchen','courier','viewer') then raise exception 'Сотруднику нельзя назначить роль владельца'; end if; + + select lower(email) into v_email from auth.users where id=p_user_id; + if v_email is null then raise exception 'Аккаунт сотрудника не найден'; end if; + if v_name='' then v_name:=split_part(v_email,'@',1); end if; + + select exists(select 1 from public.sun_workspace_members where workspace_id=p_workspace and user_id=p_user_id and is_active=true) into v_already; + select p.max_members into v_max + from public.sun_workspace_subscriptions s join public.sun_plans p on p.id=s.plan_id + where s.workspace_id=p_workspace; + select count(*)::int into v_count from public.sun_workspace_members where workspace_id=p_workspace and is_active=true; + if not v_already and v_max is not null and v_count>=v_max then raise exception 'Достигнут лимит пользователей тарифа (%)',v_max; end if; + + insert into public.sun_workspace_members(workspace_id,user_id,role,display_name,is_active,permissions,updated_at) + values(p_workspace,p_user_id,v_role,left(v_name,120),true,public.sun_role_default_permissions(v_role)||jsonb_build_object('users.manage',false),now()) + on conflict(workspace_id,user_id) do update + set role=excluded.role,display_name=excluded.display_name,is_active=true,permissions=excluded.permissions,updated_at=now(); + + delete from public.sun_workspace_invites where workspace_id=p_workspace and used_at is null and lower(coalesce(email,''))=v_email; + + return jsonb_build_object('status',case when v_already then 'updated' else 'added' end,'user_id',p_user_id,'email',v_email,'display_name',v_name,'role',v_role); +end; +$$; + +create or replace function public.sun_create_invite(p_workspace uuid,p_role text default 'manager') +returns uuid +language plpgsql +security definer +set search_path='public' +as $$ +declare + v_token uuid; + v_role text:=lower(coalesce(p_role,'manager')); + v_max integer; + v_count integer; +begin + if not public.caterium_is_workspace_owner(p_workspace) and not public.sun_is_platform_admin() then raise exception 'Только владелец компании может добавлять сотрудников'; end if; + if public.sun_subscription_access_mode(p_workspace)<>'full' then raise exception 'Подписка не позволяет изменять пользователей'; end if; + if not public.sun_workspace_has_feature(p_workspace,'users_manage') then raise exception 'Добавление сотрудников недоступно на текущем тарифе'; end if; + if v_role not in ('manager','kitchen','courier','viewer') then raise exception 'Некорректная роль сотрудника'; end if; + select p.max_members into v_max from public.sun_workspace_subscriptions s join public.sun_plans p on p.id=s.plan_id where s.workspace_id=p_workspace; + select count(*)::int into v_count from public.sun_workspace_members where workspace_id=p_workspace and is_active=true; + if v_max is not null and v_count>=v_max then raise exception 'Достигнут лимит сотрудников тарифа (%).',v_max; end if; + insert into public.sun_workspace_invites(workspace_id,role,permissions,created_by) + values(p_workspace,v_role,public.sun_role_default_permissions(v_role)||jsonb_build_object('users.manage',false),auth.uid()) returning token into v_token; + return v_token; +end; +$$; + +create or replace function public.sun_create_invite_v27( + p_workspace uuid, + p_email text, + p_display_name text default '', + p_role text default 'manager' +) +returns uuid +language plpgsql +security definer +set search_path='public','auth' +as $$ +declare + v_token uuid; + v_role text:=lower(coalesce(p_role,'manager')); + v_email text:=lower(trim(coalesce(p_email,''))); + v_name text:=trim(coalesce(p_display_name,'')); + v_max integer; + v_active integer; + v_pending integer; +begin + if not public.caterium_is_workspace_owner(p_workspace) and not public.sun_is_platform_admin() then raise exception 'Только владелец компании может добавлять сотрудников'; end if; + if public.sun_subscription_access_mode(p_workspace)<>'full' then raise exception 'Подписка не позволяет изменять пользователей'; end if; + if not public.sun_workspace_has_feature(p_workspace,'users_manage') then raise exception 'Добавление сотрудников недоступно на текущем тарифе'; end if; + if v_role not in ('manager','kitchen','courier','viewer') then raise exception 'Некорректная роль сотрудника'; end if; + if v_email='' or v_email !~* '^[^[:space:]@]+@[^[:space:]@]+\.[^[:space:]@]+$' then raise exception 'Введите корректный email сотрудника'; end if; + if v_name='' then v_name:=split_part(v_email,'@',1); end if; + + if exists( + select 1 from public.sun_workspace_members m + join auth.users u on u.id=m.user_id + where m.workspace_id=p_workspace and m.is_active=true and lower(coalesce(u.email,''))=v_email + ) then raise exception 'Пользователь с этим email уже добавлен в компанию'; end if; + + delete from public.sun_workspace_invites + where workspace_id=p_workspace and used_at is null + and (expires_at<=now() or lower(coalesce(email,''))=v_email); + + select p.max_members into v_max + from public.sun_workspace_subscriptions s + join public.sun_plans p on p.id=s.plan_id + where s.workspace_id=p_workspace; + + if v_max is not null then + select count(*)::int into v_active from public.sun_workspace_members where workspace_id=p_workspace and is_active=true; + select count(*)::int into v_pending from public.sun_workspace_invites where workspace_id=p_workspace and used_at is null and expires_at>now(); + if v_active+v_pending>=v_max then raise exception 'Достигнут лимит пользователей тарифа (%)',v_max; end if; + end if; + + insert into public.sun_workspace_invites(workspace_id,role,permissions,created_by,email,display_name) + values(p_workspace,v_role,public.sun_role_default_permissions(v_role)||jsonb_build_object('users.manage',false),auth.uid(),v_email,left(v_name,120)) + returning token into v_token; + return v_token; +end; +$$; + +create or replace function public.sun_list_workspace_invites_v27(p_workspace uuid) +returns table(token uuid,email text,display_name text,role text,created_at timestamptz,expires_at timestamptz,status text) +language plpgsql +stable +security definer +set search_path='public' +as $$ +begin + if not public.caterium_is_workspace_owner(p_workspace) and not public.sun_is_platform_admin() then raise exception 'Только владелец компании может просматривать приглашения'; end if; + return query + select i.token,i.email,i.display_name,i.role,i.created_at,i.expires_at, + case when i.expires_at<=now() then 'expired' else 'pending' end::text + from public.sun_workspace_invites i + where i.workspace_id=p_workspace and i.used_at is null + order by i.created_at desc; +end; +$$; + +create or replace function public.sun_cancel_invite_v27(p_workspace uuid,p_token uuid) +returns boolean +language plpgsql +security definer +set search_path='public' +as $$ +declare v_deleted integer; +begin + if not public.caterium_is_workspace_owner(p_workspace) and not public.sun_is_platform_admin() then raise exception 'Только владелец компании может отменять приглашения'; end if; + delete from public.sun_workspace_invites where workspace_id=p_workspace and token=p_token and used_at is null; + get diagnostics v_deleted=row_count; + return v_deleted>0; +end; +$$; + +revoke all on function public.sun_list_workspace_members(uuid) from public,anon; +revoke all on function public.sun_admin_update_member(uuid,uuid,text,text,boolean,jsonb) from public,anon; +revoke all on function public.sun_admin_remove_member(uuid,uuid) from public,anon; +revoke all on function public.sun_employee_prepare_v28(uuid,text,text,text) from public,anon; +revoke all on function public.sun_employee_finalize_v28(uuid,uuid,text,text) from public,anon; +revoke all on function public.sun_create_invite(uuid,text) from public,anon; +revoke all on function public.sun_create_invite_v27(uuid,text,text,text) from public,anon; +revoke all on function public.sun_list_workspace_invites_v27(uuid) from public,anon; +revoke all on function public.sun_cancel_invite_v27(uuid,uuid) from public,anon; + +grant execute on function public.sun_list_workspace_members(uuid) to authenticated; +grant execute on function public.sun_admin_update_member(uuid,uuid,text,text,boolean,jsonb) to authenticated; +grant execute on function public.sun_admin_remove_member(uuid,uuid) to authenticated; +grant execute on function public.sun_employee_prepare_v28(uuid,text,text,text) to authenticated; +grant execute on function public.sun_employee_finalize_v28(uuid,uuid,text,text) to authenticated; +grant execute on function public.sun_create_invite(uuid,text) to authenticated; +grant execute on function public.sun_create_invite_v27(uuid,text,text,text) to authenticated; +grant execute on function public.sun_list_workspace_invites_v27(uuid) to authenticated; +grant execute on function public.sun_cancel_invite_v27(uuid,uuid) to authenticated; + + +-- Source: ops/sql/SUPABASE-V17.7.0-SERVER-ORDER-AUTOMATION.sql +alter table public.sun_workspaces add column if not exists timezone text not null default 'Europe/Moscow'; + +create or replace function public.sun_order_due_at(p_data jsonb,p_timezone text) +returns timestamptz +language plpgsql +stable +set search_path='public' as $$ +declare v_date date; v_time time; v_tz text; +begin + if coalesce(p_data->>'date','') !~ '^\d{4}-\d{2}-\d{2}$' or coalesce(p_data->>'time','') !~ '^\d{2}:\d{2}$' then return null; end if; + v_date := (p_data->>'date')::date; + v_time := (p_data->>'time')::time; + v_tz := coalesce(nullif(trim(p_timezone),''),'Europe/Moscow'); + return ((v_date::timestamp + v_time) at time zone v_tz) + interval '1 minute'; +exception when others then return null; +end;$$; + +create or replace function public.sun_process_due_orders_internal(p_workspace uuid,p_now timestamptz default now()) +returns jsonb +language plpgsql +security definer +set search_path='public' as $$ +declare v_tz text; v_changed integer:=0; v_ids text[]:=array[]::text[]; r record; v_total numeric; v_iso text; v_orders jsonb; +begin + select timezone into v_tz from public.sun_workspaces where id=p_workspace; + if v_tz is null then return jsonb_build_object('changed',0,'ids','[]'::jsonb); end if; + v_iso:=to_char(p_now at time zone 'UTC','YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'); + for r in + select order_id,data,version from public.sun_v17_orders + where workspace_id=p_workspace + and lower(coalesce(data->>'status','')) not in ('отменён','отменен','отдан заказчику','завершён','завершен') + and coalesce(data->>'sunAutoCompletedAt','')='' + and public.sun_order_due_at(data,v_tz) is not null + and public.sun_order_due_at(data,v_tz) <= p_now + for update + loop + v_total:=case when coalesce(r.data->>'total','') ~ '^-?\d+(\.\d+)?$' then greatest(0,(r.data->>'total')::numeric) else 0 end; + update public.sun_v17_orders + set data=r.data||jsonb_build_object('prepayment',v_total,'balance',0,'status','Отдан заказчику','paymentStatus','paid','completedAt',coalesce(nullif(r.data->>'completedAt',''),v_iso),'paymentCompletedAt',coalesce(nullif(r.data->>'paymentCompletedAt',''),v_iso),'sunAutoCompletedAt',v_iso,'sunAutoCompletedV1770',true,'sunAutoCompletedVersion','17.7.0'),version=version+1,updated_at=p_now,updated_by=null + where workspace_id=p_workspace and order_id=r.order_id; + insert into public.sun_v17_change_events(workspace_id,entity,entity_key,operation,version,client_id,created_by) + values(p_workspace,'order',r.order_id,'upsert',r.version+1,'server:auto-complete',null); + v_changed:=v_changed+1;v_ids:=array_append(v_ids,r.order_id); + end loop; + if v_changed>0 then + select coalesce(jsonb_agg(o.data order by case when o.order_id ~ '^\d+$' then o.order_id::bigint else null end,o.order_id),'[]'::jsonb) into v_orders from public.sun_v17_orders o where o.workspace_id=p_workspace; + update public.sun_app_state s set payload=jsonb_set(coalesce(s.payload,'{}'::jsonb),'{storage,sunOrders}',jsonb_build_object('t','j','v',v_orders),true),revision=s.revision+1,updated_at=p_now,client_id='server:auto-complete' where s.workspace_id=p_workspace; + if not found then insert into public.sun_app_state(workspace_id,payload,revision,updated_at,client_id) values(p_workspace,jsonb_build_object('format','sun-cloud-v2','version',2,'storage',jsonb_build_object('sunOrders',jsonb_build_object('t','j','v',v_orders))),1,p_now,'server:auto-complete'); end if; + end if; + return jsonb_build_object('changed',v_changed,'ids',to_jsonb(v_ids),'workspace',p_workspace,'timezone',v_tz,'processed_at',v_iso); +end;$$; + +create or replace function public.sun_run_order_automation(p_workspace uuid) +returns jsonb +language plpgsql +security definer +set search_path='public' as $$ +declare v_result jsonb; v_ids text[]; v_orders 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)='blocked' then raise exception 'Подписка закончилась'; end if; + v_result:=public.sun_process_due_orders_internal(p_workspace,now()); + select coalesce(array_agg(value::text),'{}'::text[]) into v_ids from jsonb_array_elements_text(coalesce(v_result->'ids','[]'::jsonb)); + if coalesce(array_length(v_ids,1),0)>0 then select coalesce(jsonb_agg(o.data order by o.order_id),'[]'::jsonb) into v_orders from public.sun_v17_orders o where o.workspace_id=p_workspace and o.order_id=any(v_ids); else v_orders:='[]'::jsonb; end if; + return v_result||jsonb_build_object('orders',v_orders); +end;$$; + +create or replace function public.caterium_process_due_orders_all() +returns jsonb +language plpgsql +security definer +set search_path='public' as $$ +declare r record; v_total integer:=0; v_result jsonb; +begin + for r in select id from public.sun_workspaces loop v_result:=public.sun_process_due_orders_internal(r.id,now());v_total:=v_total+coalesce((v_result->>'changed')::integer,0);end loop; + return jsonb_build_object('changed',v_total,'processed_at',now()); +end;$$; + +revoke all on function public.sun_process_due_orders_internal(uuid,timestamptz) from public,anon,authenticated; +revoke all on function public.caterium_process_due_orders_all() from public,anon,authenticated; +revoke all on function public.sun_order_due_at(jsonb,text) from anon; +grant execute on function public.sun_run_order_automation(uuid) to authenticated; + +do $$ declare v_job bigint; begin + select jobid into v_job from cron.job where jobname='caterium-order-auto-complete'; + if v_job is not null then perform cron.unschedule(v_job); end if; + perform cron.schedule('caterium-order-auto-complete','* * * * *','select public.caterium_process_due_orders_all();'); +end$$; + + +-- Source: ops/sql/SUPABASE-V17.7.0-ERROR-TELEMETRY-HYGIENE.sql +create or replace function public.sun_v17_log_error(p_workspace uuid, p_client_id text, p_app_version text, p_level text, p_message text, p_stack text, p_context jsonb default '{}'::jsonb) +returns uuid +language plpgsql +security definer +set search_path='public' as $$ +declare v_id uuid; v_url text:=coalesce(p_context->>'url',''); v_message text:=left(coalesce(p_message,'Unknown error'),4000); v_level text:=left(coalesce(p_level,'error'),30); v_version text:=left(coalesce(p_app_version,''),80); +begin + if p_workspace is not null and public.sun_member_role(p_workspace) is null then raise exception 'Access denied'; end if; + if v_url ~* '^file:' then return null; end if; + select e.id into v_id from public.sun_v17_error_events e + where e.workspace_id is not distinct from p_workspace + and e.user_id is not distinct from auth.uid() + and coalesce(e.app_version,'')=v_version + and coalesce(e.level,'error')=v_level + and e.message=v_message + and e.created_at>=now()-interval '5 minutes' + order by e.created_at desc limit 1; + if v_id is not null then return v_id; end if; + insert into public.sun_v17_error_events(workspace_id,user_id,client_id,app_version,level,message,stack,context) + values(p_workspace,auth.uid(),left(p_client_id,120),v_version,v_level,v_message,left(coalesce(p_stack,''),12000),coalesce(p_context,'{}'::jsonb)) returning id into v_id; + return v_id; +end;$$; + +delete from public.sun_v17_error_events where coalesce(context->>'url','') ~* '^file:'; + +do $$ declare v_job bigint; begin + select jobid into v_job from cron.job where jobname='caterium-error-retention'; + if v_job is not null then perform cron.unschedule(v_job); end if; + perform cron.schedule('caterium-error-retention','23 3 * * *',$cron$delete from public.sun_v17_error_events where created_at < now()-interval '30 days';$cron$); +end$$; + + +-- Source: ops/sql/SUPABASE-V17.7.2-CLIENTS-FOUNDATION.sql +-- Caterium v17.7.2 — normalized client foundation +-- Additive migration: preserves legacy state and existing client rows. + +create or replace function public.sun_v17_save_client_v1772( + p_workspace uuid, + p_client_key text, + p_profile jsonb, + p_expected_version bigint default null, + p_client_id text default null +) +returns table(client_key text, version bigint, data jsonb, updated_at timestamptz) +language plpgsql +security definer +set search_path = public +as $function$ +declare + cur bigint; + saved public.sun_v17_clients%rowtype; + next_name text; + next_phone text; + next_address text; +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' + or not public.sun_workspace_has_feature(p_workspace,'clients') then + raise exception 'Клиенты недоступны для изменения'; + end if; + if not public.sun_has_permission(p_workspace,'clients.edit') then + raise exception 'Нет права изменять клиентов'; + end if; + if coalesce(nullif(trim(p_client_key),''),'') = '' then + raise exception 'Client key is required'; + end if; + if jsonb_typeof(coalesce(p_profile,'{}'::jsonb)) <> 'object' then + raise exception 'Invalid client profile'; + end if; + + select c.version into cur + from public.sun_v17_clients c + where c.workspace_id=p_workspace and c.client_key=p_client_key + for update; + + if found and p_expected_version is not null and cur <> p_expected_version then + raise exception using errcode='40001',message=format('SUN_CLIENT_CONFLICT expected=%s actual=%s',p_expected_version,cur); + end if; + + next_name=nullif(trim(coalesce(p_profile#>>'{identity,name}',p_profile->>'name','')),''); + next_phone=nullif(trim(coalesce(p_profile#>>'{identity,phone}',p_profile->>'phone','')),''); + next_address=nullif(trim(coalesce(p_profile#>>'{identity,latestAddress}',p_profile->>'latestAddress','')),''); + + insert into public.sun_v17_clients(workspace_id,client_key,name,phone,latest_address,data,version,created_at,updated_at) + values(p_workspace,p_client_key,next_name,next_phone,next_address,p_profile,1,now(),now()) + on conflict on constraint sun_v17_clients_pkey do update set + name=coalesce(excluded.name,sun_v17_clients.name), + phone=coalesce(excluded.phone,sun_v17_clients.phone), + latest_address=coalesce(excluded.latest_address,sun_v17_clients.latest_address), + data=excluded.data, + version=case when sun_v17_clients.data is distinct from excluded.data then sun_v17_clients.version+1 else sun_v17_clients.version end, + updated_at=case when sun_v17_clients.data is distinct from excluded.data then now() else sun_v17_clients.updated_at end + returning * into saved; + + if not found or cur is distinct from saved.version then + insert into public.sun_v17_change_events(workspace_id,entity,entity_key,operation,version,client_id,created_by) + values(p_workspace,'client',p_client_key,'upsert',saved.version,p_client_id,auth.uid()); + end if; + + return query select saved.client_key,saved.version,saved.data,saved.updated_at; +end; +$function$; + +revoke execute on function public.sun_v17_save_client_v1772(uuid,text,jsonb,bigint,text) from public, anon; +grant execute on function public.sun_v17_save_client_v1772(uuid,text,jsonb,bigint,text) to authenticated, service_role; + + +-- Source: ops/sql/SUPABASE-V17.7.3-CLIENTS-SERVER-READ.sql +-- Caterium v17.7.3 — canonical client server-read snapshot +-- Read-only/additive migration. Existing p:/n:/o: rows are preserved. + +create or replace function public.sun_v17_clients_snapshot_v1773(p_workspace uuid) +returns table( + client_key text, + name text, + phone text, + latest_address text, + data jsonb, + version bigint, + updated_at timestamptz, + source_keys text[], + source_count bigint, + canonical_row_present boolean +) +language plpgsql +security definer +set search_path = public +as $function$ +begin + if public.sun_member_role(p_workspace) is null then + raise exception 'Access denied'; + end if; + if not public.sun_workspace_has_feature(p_workspace,'clients') then + raise exception 'Клиенты недоступны'; + end if; + + return query + with prepared as ( + select c.*, + regexp_replace(coalesce(nullif(trim(c.phone),''),nullif(trim(c.data#>>'{identity,phone}'),''),nullif(trim(c.data->>'phone'),''),''),'[^0-9]','','g') as phone_digits, + lower(regexp_replace(coalesce(nullif(trim(c.name),''),nullif(trim(c.data#>>'{identity,name}'),''),nullif(trim(c.data->>'name'),''),''),'[[:space:]]+',' ','g')) as name_norm + from public.sun_v17_clients c + where c.workspace_id=p_workspace + ), canonical as ( + select p.*,case when p.phone_digits<>'' then 'p:'||p.phone_digits when p.name_norm<>'' then 'n:'||p.name_norm else p.client_key end as canonical_key + from prepared p + ), ranked as ( + select c.*,row_number() over(partition by c.canonical_key order by (c.client_key=c.canonical_key) desc,c.updated_at desc,c.version desc,c.client_key) as rn + from canonical c + ), grouped as ( + select c.canonical_key,array_agg(c.client_key order by (c.client_key=c.canonical_key) desc,c.updated_at desc,c.client_key) as source_keys,count(*)::bigint as source_count,bool_or(c.client_key=c.canonical_key) as canonical_row_present + from canonical c group by c.canonical_key + ) + select r.canonical_key, + coalesce(nullif(trim(r.name),''),nullif(trim(r.data#>>'{identity,name}'),''),nullif(trim(r.data->>'name'),'')), + coalesce(nullif(trim(r.phone),''),nullif(trim(r.data#>>'{identity,phone}'),''),nullif(trim(r.data->>'phone'),'')), + coalesce(nullif(trim(r.latest_address),''),nullif(trim(r.data#>>'{identity,latestAddress}'),''),nullif(trim(r.data->>'latestAddress'),'')), + coalesce(r.data,'{}'::jsonb),r.version,r.updated_at,g.source_keys,g.source_count,g.canonical_row_present + from ranked r join grouped g on g.canonical_key=r.canonical_key + where r.rn=1 + order by r.updated_at desc,r.canonical_key; +end; +$function$; + +revoke execute on function public.sun_v17_clients_snapshot_v1773(uuid) from public, anon; +grant execute on function public.sun_v17_clients_snapshot_v1773(uuid) to authenticated, service_role; + + +-- Source: ops/sql/SUPABASE-V17.7.4-AUTH-EMAIL-VERIFICATION.sql +-- Caterium v17.7.4: require real Supabase email verification for client-created accounts. +-- The removed triggers trusted raw_user_meta_data.registration_source, which is client-controlled. +drop trigger if exists caterium_autoconfirm_signup_v25 on auth.users; +drop trigger if exists caterium_autoverify_identity_v25 on auth.identities; + +drop function if exists public.caterium_autoconfirm_signup_v25(); +drop function if exists public.caterium_autoverify_identity_v25(); + +-- Source: ops/sql/SUPABASE-V17.7.4-ADVISOR-HARDENING.sql + +-- Keep the same RLS semantics while evaluating auth.uid() once per statement. +alter policy sun_chat_reads_read_v29 on public.sun_chat_reads + using ((user_id = (select auth.uid())) and public.sun_chat_can_access_thread_v29(thread_id)); + +-- Cover foreign keys used by chat joins/deletes. +create index if not exists sun_chat_messages_sender_user_id_idx on public.sun_chat_messages(sender_user_id); +create index if not exists sun_chat_participants_workspace_id_idx on public.sun_chat_participants(workspace_id); +create index if not exists sun_chat_reads_workspace_id_idx on public.sun_chat_reads(workspace_id); +create index if not exists sun_chat_threads_created_by_idx on public.sun_chat_threads(created_by); + +-- Remove exact duplicate indexes, retaining the clearer canonical names. +drop index if exists public.sun_v17_error_workspace_created_idx; +drop index if exists public.sun_v17_orders_due_idx; + +-- Server order automation is for signed-in workspace members only. +revoke execute on function public.sun_run_order_automation(uuid) from public; +revoke execute on function public.sun_run_order_automation(uuid) from anon; +grant execute on function public.sun_run_order_automation(uuid) to authenticated; +grant execute on function public.sun_run_order_automation(uuid) to service_role; + +-- Source: ops/sql/SUPABASE-FRESH-PROMOS-AND-ADMIN.sql +-- Missing production RPC contracts reconstructed for the empty Caterium project. +create table public.caterium_trial_promos ( + id uuid primary key default gen_random_uuid(),code text not null unique, + client_email text,trial_days integer not null default 14 check(trial_days between 1 and 365), + plan_id text not null default 'full' references public.sun_plans(id), + max_uses integer not null default 1 check(max_uses between 1 and 10000),use_count integer not null default 0, + is_active boolean not null default true,valid_until timestamptz,note text, + created_by uuid references auth.users(id) on delete set null,created_at timestamptz not null default now(),updated_at timestamptz not null default now() +); +create table public.caterium_trial_redemptions ( + id uuid primary key default gen_random_uuid(),promo_id uuid not null references public.caterium_trial_promos(id), + workspace_id uuid not null unique references public.sun_workspaces(id) on delete cascade, + user_id uuid not null unique references auth.users(id) on delete cascade,email text,trial_ends_at timestamptz, + created_at timestamptz not null default now() +); +alter table public.caterium_trial_promos enable row level security; +alter table public.caterium_trial_redemptions enable row level security; +revoke all on public.caterium_trial_promos,public.caterium_trial_redemptions from anon,authenticated; +create function public.caterium_normalize_trial_code(p_code text) returns text language sql immutable set search_path=public as $$select upper(regexp_replace(trim(coalesce(p_code,'')),'[[:space:]]','','g'))$$; +create function public.caterium_trial_promo_preview(p_code text,p_email text default null) +returns jsonb language plpgsql stable security definer set search_path=public as $$ +declare p public.caterium_trial_promos%rowtype; +begin + select * into p from public.caterium_trial_promos where code=public.caterium_normalize_trial_code(p_code); + if not found or not p.is_active or (p.valid_until is not null and p.valid_until<=now()) or p.use_count>=p.max_uses + or (p.client_email is not null and p.client_email<>lower(trim(coalesce(p_email,'')))) then + return jsonb_build_object('valid',false,'reason','Промокод недействителен для этого email или срок его действия истёк'); + end if; + return jsonb_build_object('valid',true,'trial_days',p.trial_days,'plan',p.plan_id); +end $$; +create function public.sun_dev_create_trial_promo(p_code text default null,p_email text default null,p_trial_days integer default 14,p_valid_days integer default 7,p_max_uses integer default 1,p_plan text default 'full',p_note text default null) +returns jsonb language plpgsql security definer set search_path=public as $$ +declare p public.caterium_trial_promos%rowtype; c text; +begin + perform public.sun_require_platform_admin_aal2(); + c:=coalesce(nullif(public.caterium_normalize_trial_code(p_code),''),'CTM-'||upper(replace(gen_random_uuid()::text,'-',''))::varchar(16)); + if c !~ '^[A-Z0-9-]{3,32}$' then raise exception 'Некорректный промокод'; end if; + if p_valid_days not between 1 and 365 then raise exception 'Некорректный срок'; end if; + insert into public.caterium_trial_promos(code,client_email,trial_days,valid_until,max_uses,plan_id,note,created_by) + values(c,nullif(lower(trim(p_email)),''),p_trial_days,now()+make_interval(days=>p_valid_days),p_max_uses,p_plan,p_note,auth.uid()) returning * into p; + perform public.sun_platform_log_event('trial_promo.create',null,null,jsonb_build_object('promo_id',p.id)); + return jsonb_build_object('promo_id',p.id,'code',p.code,'trial_days',p.trial_days,'valid_until',p.valid_until); +end $$; +create function public.sun_dev_list_trial_promos(p_limit integer default 300) +returns table(promo_id uuid,code text,client_email text,trial_days integer,valid_until timestamptz,max_uses integer,use_count integer,is_active boolean,last_redeemed_at timestamptz,last_workspace_name text,last_redeemed_email text) +language plpgsql stable security definer set search_path=public as $$ +begin + perform public.sun_require_platform_admin_aal2(); + return query select p.id,p.code,p.client_email,p.trial_days,p.valid_until,p.max_uses,p.use_count,p.is_active,r.created_at,w.name,r.email + from public.caterium_trial_promos p left join lateral (select x.* from public.caterium_trial_redemptions x where x.promo_id=p.id order by x.created_at desc limit 1) r on true + left join public.sun_workspaces w on w.id=r.workspace_id order by p.created_at desc limit greatest(1,least(coalesce(p_limit,300),1000)); +end $$; +create function public.sun_dev_set_trial_promo_active(p_promo uuid,p_active boolean) +returns void language plpgsql security definer set search_path=public as $$ +begin + perform public.sun_require_platform_admin_aal2(); + update public.caterium_trial_promos set is_active=p_active,updated_at=now() where id=p_promo; + if not found then raise exception 'Промокод не найден'; end if; + perform public.sun_platform_log_event('trial_promo.set_active',null,null,jsonb_build_object('promo_id',p_promo,'active',p_active)); +end $$; + +create function public.caterium_platform_create_company(p_name text,p_owner_email text default null,p_plan text default 'full',p_days integer default 30,p_mode text default 'empty') +returns jsonb language plpgsql security definer set search_path=public,auth as $$ +declare v_ws uuid; v_owner uuid; v_email text:=nullif(lower(trim(p_owner_email)),''); v_token uuid; +begin + perform public.sun_require_platform_admin_aal2(); + if p_days not between 1 and 3650 then raise exception 'Invalid subscription duration'; end if; + select id into v_owner from auth.users where lower(email)=v_email and email_confirmed_at is not null; + insert into public.sun_workspaces(name,created_by) values(coalesce(nullif(trim(p_name),''),'Новая компания'),coalesce(v_owner,auth.uid())) returning id into v_ws; + if v_owner is not null then + insert into public.sun_workspace_members(workspace_id,user_id,role,is_active,permissions,display_name) + values(v_ws,v_owner,'admin',true,public.sun_role_default_permissions('admin'),split_part(v_email,'@',1)); + elsif v_email is not null then + insert into public.caterium_company_owner_invites(workspace_id,email) values(v_ws,v_email) returning token into v_token; + end if; + insert into public.sun_app_state(workspace_id,client_id) values(v_ws,'platform-bootstrap'); + insert into public.sun_workspace_subscriptions(workspace_id,plan_id,status,current_period_start,current_period_end,grace_until,source) + values(v_ws,p_plan,'active',now(),now()+make_interval(days=>p_days),now()+make_interval(days=>p_days+7),'platform'); + return jsonb_build_object('workspace_id',v_ws,'owner_user_id',v_owner,'owner_email',v_email,'owner_invite_token',v_token); +end $$; + +-- Recover the sun_dev_* names used by the client, preserving the retained +-- server implementation and requiring AAL2 at every platform boundary. +do $recovery$ +declare entry record; f record; call_args text; command text; +begin + for entry in select * from (values + ('sun_dev_dashboard','sun_platform_dashboard'),('sun_dev_list_activity','sun_platform_list_activity'), + ('sun_dev_list_companies','sun_platform_list_companies_v22'),('sun_dev_list_users','sun_platform_list_users_v22'), + ('sun_dev_list_errors','sun_platform_list_errors_v22'),('sun_dev_support_snapshot','sun_platform_support_snapshot'), + ('sun_dev_workspace_diagnostics','sun_platform_workspace_diagnostics'),('sun_dev_list_workspace_features','sun_platform_list_workspace_features'), + ('sun_dev_log_event','sun_platform_log_event'),('sun_dev_set_plan_feature','sun_platform_set_plan_feature'), + ('sun_dev_set_plan_max_members','sun_platform_set_plan_max_members'),('sun_dev_seed_workspace_catalog','sun_platform_seed_workspace_catalog'), + ('sun_dev_reset_feature_override','sun_platform_reset_feature_override'),('sun_dev_create_company','sun_platform_create_company_v22'), + ('sun_dev_set_subscription','sun_platform_set_subscription'),('sun_dev_set_feature_override','sun_platform_set_feature_override') + ) names(alias_name,source_name) loop + select p.*,pg_get_function_arguments(p.oid) as args,pg_get_function_result(p.oid) as result into strict f + from pg_proc p join pg_namespace n on n.oid=p.pronamespace where n.nspname='public' and p.proname=entry.source_name; + select coalesce(string_agg('$'||i,',' order by i),'') into call_args from generate_series(1,f.pronargs) i; + command:=case when f.proretset then 'return query select * from' when f.prorettype='void'::regtype then 'perform' else 'return' end; + execute format('create function public.%I(%s) returns %s language plpgsql security definer set search_path=public,auth as $body$ begin perform public.sun_require_platform_admin_aal2(); %s public.%I(%s); end $body$',entry.alias_name,f.args,f.result,command,entry.source_name,call_args); + end loop; +end $recovery$; + +revoke all on function public.caterium_normalize_trial_code(text),public.caterium_trial_promo_preview(text,text),public.caterium_platform_create_company(text,text,text,integer,text) from public,anon,authenticated; +grant execute on function public.caterium_trial_promo_preview(text,text) to anon,authenticated; +do $$declare f record;begin + for f in select p.oid::regprocedure as signature from pg_proc p join pg_namespace n on n.oid=p.pronamespace where n.nspname='public' and p.proname like 'sun_dev_%' loop + execute format('revoke all on function %s from public,anon',f.signature); + execute format('grant execute on function %s to authenticated',f.signature); + end loop; +end $$; + + +-- Source: ops/sql/SUPABASE-V17.8.2-OWNER-WORKSPACE-ONBOARDING.sql +-- Caterium v17.8.2 +-- Public company creation belongs only to a new owner redeeming a trial promo. +-- Employees are attached to an existing workspace through membership/invite flows. +create or replace function public.sun_create_workspace(p_name text default 'Новая компания'::text) +returns uuid +language plpgsql +security definer +set search_path = public, auth, pg_temp +as $function$ +declare + v_user uuid:=auth.uid(); + v_workspace uuid; + v_name text:='Новая компания'; + v_email text; + v_profile jsonb; + v_storage jsonb; + v_code text; + v_promo public.caterium_trial_promos%rowtype; + v_trial_end timestamptz; +begin + if v_user is null then raise exception 'Authentication required'; end if; + if not exists(select 1 from auth.users where id=v_user and email_confirmed_at is not null) then raise exception 'Подтвердите email'; end if; + if exists(select 1 from public.sun_workspace_members where user_id=v_user and is_active=true) then + raise exception 'Аккаунт уже относится к компании'; + end if; + + select lower(email),public.caterium_normalize_trial_code(raw_user_meta_data->>'promo_code') + into v_email,v_code from auth.users where id=v_user; + if coalesce(v_code,'')='' then raise exception 'Для создания новой компании нужен промокод пробной версии'; end if; + if exists(select 1 from public.caterium_trial_redemptions where user_id=v_user) then raise exception 'Пробный период для этого аккаунта уже использован'; end if; + + select * into v_promo from public.caterium_trial_promos where code=v_code for update; + if not found then raise exception 'Промокод не найден'; end if; + if not v_promo.is_active then raise exception 'Промокод отключён'; end if; + if v_promo.valid_until is not null and v_promo.valid_until<=now() then raise exception 'Срок действия промокода истёк'; end if; + if v_promo.use_count>=v_promo.max_uses then raise exception 'Промокод уже использован'; end if; + if v_promo.client_email is not null and lower(v_promo.client_email)<>v_email then raise exception 'Промокод предназначен для другого email'; end if; + + v_trial_end:=now()+make_interval(days=>v_promo.trial_days); + insert into public.sun_workspaces(name,created_by) values(v_name,v_user) returning id into v_workspace; + insert into public.sun_workspace_members(workspace_id,user_id,role,display_name,is_active,permissions) + values(v_workspace,v_user,'admin',coalesce((select raw_user_meta_data->>'name' from auth.users where id=v_user),split_part(coalesce(v_email,'Администратор'),'@',1)),true,public.sun_role_default_permissions('admin')); + + v_profile:=jsonb_build_object('name','','shortName','','logo','','tagline','','city','','phone','','email',coalesce(v_email,''),'website','','address','','legalName','','inn','','kpp','','ogrn','','legalAddress','','bank','','bik','','account','','corrAccount','','legacyLocked',false); + v_storage:=jsonb_build_object('sunCompanyProfileV1',v_profile::text); + insert into public.sun_app_state(workspace_id,payload,client_id) + values(v_workspace,jsonb_build_object('format','sun-cloud-v2','version',2,'storage',v_storage),'bootstrap') on conflict(workspace_id) do nothing; + + insert into public.sun_workspace_subscriptions(workspace_id,plan_id,status,trial_started_at,trial_ends_at,grace_until,source,note) + values(v_workspace,v_promo.plan_id,'trialing',now(),v_trial_end,v_trial_end+interval '7 days','promo_trial','Trial by promo '||v_promo.code) + on conflict(workspace_id) do update set plan_id=excluded.plan_id,status=excluded.status,trial_started_at=excluded.trial_started_at,trial_ends_at=excluded.trial_ends_at,grace_until=excluded.grace_until,source=excluded.source,note=excluded.note,updated_at=now(); + + insert into public.caterium_trial_redemptions(promo_id,workspace_id,user_id,email,trial_ends_at) + values(v_promo.id,v_workspace,v_user,v_email,v_trial_end); + update public.caterium_trial_promos set use_count=use_count+1,updated_at=now() where id=v_promo.id; + update auth.users set raw_user_meta_data=coalesce(raw_user_meta_data,'{}'::jsonb)-'promo_code'-'company_name' where id=v_user; + insert into public.sun_platform_audit_events(actor_user_id,action,target_workspace_id,target_user_id,details) + values(v_user,'trial_promo.redeem',v_workspace,v_user,jsonb_build_object('promo_id',v_promo.id,'code',v_promo.code,'trial_days',v_promo.trial_days,'plan',v_promo.plan_id)); + return v_workspace; +end; +$function$; + +revoke all on function public.sun_create_workspace(text) from public,anon; +grant execute on function public.sun_create_workspace(text) to authenticated,service_role; + +-- Source: ops/sql/SUPABASE-FRESH-FINAL-GUARDS.sql +-- Explicit API grants, with RLS on every application table. +do $$ declare f record;begin + for f in select p.oid::regprocedure as signature from pg_proc p join pg_namespace n on n.oid=p.pronamespace + where n.nspname='public' and (p.proname like 'sun_%' or p.proname like 'caterium_%') loop + execute format('revoke execute on function %s from public,anon',f.signature); + end loop; +end $$; +grant execute on function public.caterium_trial_promo_preview(text,text),public.sun_invite_preview_v27(uuid) to anon,authenticated; +grant execute on function public.sun_v17_log_error(uuid,text,text,text,text,text,jsonb) to authenticated; + +-- No direct full-state writes/reads: retain the subscription/permission checks. +revoke all on public.sun_app_state from anon,authenticated; + +-- Enforce MFA even if a caller uses an older public platform RPC name. +do $$ declare f record;definition text;begin + for f in select p.oid from pg_proc p join pg_namespace n on n.oid=p.pronamespace + where n.nspname='public' and p.proname like 'sun_platform_%' and p.prosrc like '%if not public.sun_is_platform_admin() then raise exception ''Platform administrator required''; end if;%' loop + definition:=replace(pg_get_functiondef(f.oid),'if not public.sun_is_platform_admin() then raise exception ''Platform administrator required''; end if;','perform public.sun_require_platform_admin_aal2();'); + execute definition; + end loop; +end $$; + +create or replace function public.sun_v17_entity_snapshot(p_workspace uuid) +returns jsonb language plpgsql stable security definer set search_path=public as $$ +declare is_admin boolean:=public.sun_is_platform_admin(); +begin + if is_admin then perform public.sun_require_platform_admin_aal2(); + elsif public.sun_member_role(p_workspace) is null then raise exception 'Access denied'; end if; + if not is_admin and public.sun_subscription_access_mode(p_workspace)='blocked' then raise exception 'Подписка закончилась'; end if; + return jsonb_build_object( + 'orders',case when is_admin or (public.sun_has_permission(p_workspace,'orders.view') and public.sun_workspace_has_feature(p_workspace,'orders')) then coalesce((select jsonb_agg(jsonb_build_object('id',order_id,'version',version,'data',data,'updated_at',updated_at) order by order_id) from public.sun_v17_orders where workspace_id=p_workspace),'[]') else '[]'::jsonb end, + 'catalog',case when is_admin or (public.sun_has_permission(p_workspace,'catalog.view') and public.sun_workspace_has_feature(p_workspace,'catalog_view')) then coalesce((select jsonb_agg(jsonb_build_object('id',item_id,'version',version,'data',data,'updated_at',updated_at) order by item_id) from public.sun_v17_catalog_items where workspace_id=p_workspace),'[]') else '[]'::jsonb end, + 'meta',coalesce((select to_jsonb(m) from public.sun_v17_workspace_meta m where workspace_id=p_workspace),'{}')); +end $$; + +-- Authenticated roles can invoke public API guards but cannot invoke snapshot internals. +grant execute on function public.sun_v17_entity_snapshot(uuid) to authenticated; +revoke all on function public.sun_v17_build_snapshot(uuid),public.sun_require_platform_admin_aal2() from public,anon,authenticated; + +-- Keep owner-registration metadata in the same typed format as the sync client. +do $$declare definition text;begin + definition:=pg_get_functiondef('public.sun_create_workspace(text)'::regprocedure); + definition:=replace(definition,'''sunCompanyProfileV1'',v_profile::text','''sunCompanyProfileV1'',jsonb_build_object(''t'',''j'',''v'',v_profile)'); + execute definition; +end $$; + +-- Match client visibility to the same granular permission as state reads. +do $$declare definition text;begin + definition:=pg_get_functiondef('public.sun_v17_clients_snapshot_v1773(uuid)'::regprocedure); + definition:=replace(definition,'if not public.sun_workspace_has_feature(p_workspace,''clients'') then','if not public.sun_has_permission(p_workspace,''clients.view'') or public.sun_subscription_access_mode(p_workspace)=''blocked'' or not public.sun_workspace_has_feature(p_workspace,''clients'') then'); + execute definition; +end $$; + + +notify pgrst,'reload schema'; +commit; +select (select count(*) from pg_tables where schemaname='public') as tables,(select count(*) from public.sun_workspaces) as workspaces,(select count(*) from auth.users) as users; diff --git a/tests/backend-cutover.mjs b/tests/backend-cutover.mjs new file mode 100644 index 0000000..f374b21 --- /dev/null +++ b/tests/backend-cutover.mjs @@ -0,0 +1,11 @@ +import fs from 'node:fs'; +import vm from 'node:vm'; +import assert from 'node:assert/strict'; +const source=fs.readFileSync('public/app-runtime.js','utf8'); +const code=source.slice(source.indexOf(' function loadConfig() {'),source.indexOf(' function saveConfig() {')); +const url='https://usfjwhztqoopzzfmfbis.supabase.co',key='sb_publishable_CAxfhMKrduJjuk_5ybCQLg_TqSGWGoy'; +const run=config=>{const storage=new Map([['sunCloudV2Config',JSON.stringify(config)],['sunOrders','[{"id":"old-order"}]']]);const ctx={DEFAULT_SUPABASE_URL:url,DEFAULT_SUPABASE_KEY:key,CONFIG_KEY:'sunCloudV2Config',localStorage:{getItem:k=>storage.get(k),setItem:(k,v)=>storage.set(k,v)}};vm.createContext(ctx);const result=vm.runInContext(code+';loadConfig()',ctx);return{result,storage};}; +for(const retired of ['https://cksuehzcimitsxmeloes.supabase.co','https://api.caterium.ru']){const {result,storage}=run({url:retired,key:'old-key',workspaceId:'old-company',autoSync:false});assert.equal(result.url,url);assert.equal(result.key,key);assert.equal(result.workspaceId,'');assert.equal(result.legacyLocalWorkspaceId,'old-company');assert.equal(result.autoSync,false);assert.equal(storage.get('sunOrders'),'[{"id":"old-order"}]');assert.ok(storage.has('cateriumRetiredBackend20260917'));} +const {result}=run({url,key,workspaceId:'new-company',localWorkspaceId:'new-company',tenantStorageReady:true});assert.equal(result.workspaceId,'new-company');assert.equal(result.localWorkspaceId,'new-company'); +assert.equal(run({}).result.url,url); +console.log('PASS backend cutover: retired config upgraded, old local data preserved separately, fresh config stable'); diff --git a/tests/recovery/bundle.mjs b/tests/recovery/bundle.mjs new file mode 100644 index 0000000..172ee2d --- /dev/null +++ b/tests/recovery/bundle.mjs @@ -0,0 +1,11 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +const root=fileURLToPath(new URL('../../',import.meta.url)); +const files=JSON.parse(fs.readFileSync(new URL('manifest.json',import.meta.url),'utf8')); +let sql=`-- Caterium fresh recovery; target usfjwhztqoopzzfmfbis only.\n-- Do not run against an existing database.\nbegin;\nset local lock_timeout='10s';\nset local statement_timeout='120s';\ndo $$begin if to_regclass('public.sun_workspaces') is not null then raise exception 'Fresh recovery requires an empty Caterium schema'; end if; end $$;\ncreate extension if not exists pg_cron;\n`; +for(const file of files)sql+=`\n-- Source: ${file}\n`+fs.readFileSync(path.join(root,file),'utf8').replace(/^\s*(begin|commit);\s*$/gim,'')+'\n'; +sql+=`\nnotify pgrst,'reload schema';\ncommit;\nselect (select count(*) from pg_tables where schemaname='public') as tables,(select count(*) from public.sun_workspaces) as workspaces,(select count(*) from auth.users) as users;\n`; +fs.mkdirSync(path.join(root,'supabase/migrations'),{recursive:true}); +fs.writeFileSync(path.join(root,'supabase/migrations/20260917150000_fresh_caterium.sql'),sql); +console.log(JSON.stringify({bytes:Buffer.byteLength(sql),sources:files.length})); diff --git a/tests/recovery/manifest.json b/tests/recovery/manifest.json new file mode 100644 index 0000000..1f9074b --- /dev/null +++ b/tests/recovery/manifest.json @@ -0,0 +1,27 @@ +[ + "ops/sql/SUPABASE-SETUP.sql", + "ops/sql/SUPABASE-RBAC-V3.sql", + "ops/sql/SUPABASE-SAAS-V16.sql", + "ops/sql/SUPABASE-SAAS-V16-FINALIZE.sql", + "ops/sql/SUPABASE-FRESH-V17-FOUNDATION.sql", + "ops/sql/SUPABASE-V17.8-DEVELOPER-PANEL.sql", + "ops/sql/SUPABASE-V17.9-DEVELOPER-SETTINGS.sql", + "ops/sql/SUPABASE-DEVELOPER-V22.sql", + "ops/sql/SUPABASE-DEVELOPER-V22-AAL2.sql", + "ops/sql/SUPABASE-REGISTRATION-USERS-V27.sql", + "ops/sql/SUPABASE-EMPLOYEE-MANAGEMENT-V28.sql", + "ops/sql/SUPABASE-CHAT-V29.sql", + "ops/sql/SUPABASE-ADMIN-RIGHTS-V30.sql", + "ops/sql/SUPABASE-V17.6.1-CHAT-STORAGE-GUARD.sql", + "ops/sql/SUPABASE-V17.6.8-DEVELOPER-CONSOLE-UX.sql", + "ops/sql/CATERIUM-OWNER-ACCOUNTS-V31.sql", + "ops/sql/SUPABASE-V17.7.0-SERVER-ORDER-AUTOMATION.sql", + "ops/sql/SUPABASE-V17.7.0-ERROR-TELEMETRY-HYGIENE.sql", + "ops/sql/SUPABASE-V17.7.2-CLIENTS-FOUNDATION.sql", + "ops/sql/SUPABASE-V17.7.3-CLIENTS-SERVER-READ.sql", + "ops/sql/SUPABASE-V17.7.4-AUTH-EMAIL-VERIFICATION.sql", + "ops/sql/SUPABASE-V17.7.4-ADVISOR-HARDENING.sql", + "ops/sql/SUPABASE-FRESH-PROMOS-AND-ADMIN.sql", + "ops/sql/SUPABASE-V17.8.2-OWNER-WORKSPACE-ONBOARDING.sql", + "ops/sql/SUPABASE-FRESH-FINAL-GUARDS.sql" +] diff --git a/tests/recovery/smoke.mjs b/tests/recovery/smoke.mjs new file mode 100644 index 0000000..ae65589 --- /dev/null +++ b/tests/recovery/smoke.mjs @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +export async function run(db){ +const q=(s,p=[])=>db.query(s,p);const one=async(s,p)=>(await q(s,p)).rows[0]; +const ids=['00000000-0000-4000-8000-000000000001','00000000-0000-4000-8000-000000000002','00000000-0000-4000-8000-000000000003','00000000-0000-4000-8000-000000000004']; +async function actor(id,aal='aal1'){await db.exec('reset role');await q("select set_config('request.jwt.claim.sub',$1,false),set_config('request.jwt.claims',$2,false)",[id,JSON.stringify({sub:id,aal,role:'authenticated'})]);await db.exec('set role authenticated');} +async function denied(s,p,re){await assert.rejects(q(s,p),re);} +await db.exec(`create function realtime.send(jsonb,text,text,boolean) returns void language plpgsql as $$begin end$$;`); +for(let i=0;i>'role','authenticated')$$; +create table storage.buckets(id text primary key,name text,public boolean,file_size_limit bigint,allowed_mime_types text[]); +create table storage.objects(id uuid primary key default gen_random_uuid(),bucket_id text,name text,owner uuid,metadata jsonb); +alter table storage.objects enable row level security; +create function storage.foldername(text) returns text[] language sql immutable as $$select string_to_array($1,'/')$$; +create table cron.job(jobid bigserial,jobname text); +create function cron.unschedule(bigint) returns boolean language sql as $$select true$$; +create function cron.schedule(text,text,text) returns bigint language sql as $$select 1::bigint$$; +create publication supabase_realtime; +grant usage on schema public,auth,storage to anon,authenticated,service_role; +grant execute on all functions in schema auth to anon,authenticated,service_role;`); +const files=JSON.parse(fs.readFileSync(new URL('manifest.json',import.meta.url),'utf8')); +for(const file of files){try{await db.exec(fs.readFileSync(new URL(file,root),'utf8'));console.log('PASS '+file);}catch(e){console.error(JSON.stringify({file,message:e.message,detail:e.detail,where:e.where,position:e.position},null,2));process.exit(1);}} +console.log((await db.query(`select count(*) as tables from pg_tables where schemaname='public'`)).rows); +if(process.argv.includes('--smoke')){try{await (await import('./smoke.mjs')).run(db);}catch(e){console.error(JSON.stringify({message:e.message,detail:e.detail,where:e.where,query:e.query,internalQuery:e.internalQuery},null,2));process.exit(1);}} +else await db.close(); diff --git a/tests/release-check.mjs b/tests/release-check.mjs index 4877597..53145be 100644 --- a/tests/release-check.mjs +++ b/tests/release-check.mjs @@ -14,8 +14,8 @@ check(!index.includes('offer-gallery-data.js'),'blocking Base64 gallery absent') check((runtime.match(/\/Type \/Catalog/g)||[]).length===0,'runtime contains no PDF binary writer'); check(read('core/pdf-engine.js').includes('595.28')&&read('core/pdf-engine.js').includes('841.89'),'PDF engine uses A4 MediaBox'); check([...index.matchAll(/@page\{([^}]*)\}/g)].every(m=>/size:A4/i.test(m[1])),'compact @page rules use A4'); -check(sw.includes('v81-20260912-account-center-loader')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js')&&sw.includes('offer-workspace-v1769.js'),'service worker cache is v17.7.3'); -check(index.includes('20260916-v18-1-2-banquet-menu-tab')&&index.includes('classic-offer-pdf-v1767.js')&&!index.includes('20260907-v17-6-0-stability-security'),'index cache-busting points to v17.7.3'); +check(sw.includes('v82-20260917-caterium-fresh')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js')&&sw.includes('offer-workspace-v1769.js'),'service worker cache is v17.7.3'); +check(index.includes('20260917-caterium-fresh')&&index.includes('classic-offer-pdf-v1767.js')&&!index.includes('20260907-v17-6-0-stability-security'),'index cache-busting points to v17.7.3'); check(performance.includes('SunAttachmentGuard')&&performance.includes('TARGET=2*1024*1024'),'chat photo auto-compression is versioned'); check(performance.includes("rpc('sun_dev_dashboard')")&&performance.includes('server_size')&&performance.includes('storage_size'),'Developer Console server/storage counters are versioned'); check(performance.includes('MEMORY_REFRESH_MS=30000')&&performance.includes('MEMORY_TIMEOUT_MS=8000')&&performance.includes('memoryPromise'),'Developer Console memory refresh is bounded'); @@ -39,7 +39,7 @@ check(!/sb_secret_[A-Za-z0-9_-]{20,}|service_role\s*[:=]\s*["'][A-Za-z0-9._-]{30 check(lock.version===pkg.version&&lock.packages?.['']?.version===pkg.version,'package.json and package-lock.json versions match'); check(releaseManifest.version===`v${pkg.version}`,'release manifest version matches package.json'); check(releaseManifest.channel==='production','release manifest channel is production'); -check(String(releaseManifest.pwaCache||'').includes('v81-20260912-account-center-loader'),'release manifest points to current PWA cache'); +check(String(releaseManifest.pwaCache||'').includes('v82-20260917-caterium-fresh'),'release manifest points to current PWA cache'); check(['17.6.2','17.6.3','17.6.4','17.6.5','17.6.6','17.6.7','17.6.8','17.6.9','17.7.0','17.7.1','17.7.2','17.7.3'].every(v=>fs.existsSync(path.join(root,`docs/releases/V${v}-CHANGES.txt`))),'release notes exist through v17.7.3'); check(runtime.includes('CLOUD_RPC_TIMEOUT_MS=12000')&&runtime.includes('CLOUD_CONFLICT_MAX_RETRIES=4')&&runtime.includes('retryCount'),'cloud sync has timeout and capped exponential conflict retries'); check(runtime.includes("const VERSION = '17.7.3'")&&runtime.includes('ERROR_DEDUPE_MS=5*60*1000')&&runtime.includes('mirrorBusy=false')&&runtime.includes('backupBusy=false'),'stability logger uses current version, dedupe and single-flight guards'); @@ -65,8 +65,8 @@ check(offerWorkspace.includes('PDF и предпросмотр')&&offerWorkspace check(offerWorkspace.includes('SunClassicOfferPDFV1767')&&offerWorkspace.includes('finalGallery=galleryFor'),'custom gallery is injected into PDF renderer'); check(releaseManifest.offerWorkspaceTabs===true&&releaseManifest.offerTemplatesSeparateTab===true&&releaseManifest.offerTwoCustomGalleryPhotos===true,'release manifest records offer workspace changes'); check(pkg.version==='17.7.3','package version is v17.7.3'); -check(index.includes('20260916-v18-1-2-banquet-menu-tab'),'index cache bust is v17.7.3'); -check(sw.includes('v81-20260912-account-center-loader')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js'),'PWA caches v17.7.3 client foundation modules'); +check(index.includes('20260917-caterium-fresh'),'index cache bust is v17.7.3'); +check(sw.includes('v82-20260917-caterium-fresh')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js'),'PWA caches v17.7.3 client foundation modules'); check(fs.existsSync(path.join(root,'public/core/data-layer-v1773.js'))&&fs.existsSync(path.join(root,'public/core/server-automation-v1770.js')),'data layer and server automation modules exist'); check(ux.includes('CateriumServerAutomationV1770?.enabled'),'cloud browser auto completion is disabled when server automation is active'); check(runtime.includes("const VERSION = '17.7.3'")&&runtime.includes("v17.7.3 Clients Server Read"),'stability logger reports v17.7.3'); diff --git a/tests/static-security.mjs b/tests/static-security.mjs index 0139c1e..de207f8 100644 --- a/tests/static-security.mjs +++ b/tests/static-security.mjs @@ -28,8 +28,8 @@ if(current!==113)fail(`current catalog photo count ${current}, expected 113`);el if(legacyCount!==60)fail(`legacy catalog photo count ${legacyCount}, expected 60`);else ok('60 legacy catalog photos'); const gallery=fs.readdirSync(path.join(pub,'offer-gallery')).filter(x=>/\.jpg$/i.test(x)); if(gallery.length!==2)fail(`offer gallery contains ${gallery.length} jpg files, expected 2`);else ok('offer gallery trimmed'); -if(!sw.includes('20260912-account-center-loader')||!sw.includes('login-signature-v1776.js')||!sw.includes('data-layer-v1773.js')||!sw.includes('server-automation-v1770.js')||!sw.includes('offer-workspace-v1769.js')||sw.includes('offer-gallery-data.js'))fail('service worker cache is stale');else ok('PWA cache updated for login refresh'); -if(html.includes('20260907-v17-6-0-stability-security')||html.includes('20260909-v17-7-3-clients-server-read')||!html.includes('20260916-v18-1-2-banquet-menu-tab')||!html.includes('classic-offer-pdf-v1767.js'))fail('index still serves stale core asset version');else ok('index cache-busting is current'); +if(!sw.includes('20260917-caterium-fresh')||!sw.includes('login-signature-v1776.js')||!sw.includes('data-layer-v1773.js')||!sw.includes('server-automation-v1770.js')||!sw.includes('offer-workspace-v1769.js')||sw.includes('offer-gallery-data.js'))fail('service worker cache is stale');else ok('PWA cache updated for login refresh'); +if(html.includes('20260907-v17-6-0-stability-security')||html.includes('20260909-v17-7-3-clients-server-read')||!html.includes('20260917-caterium-fresh')||!html.includes('classic-offer-pdf-v1767.js'))fail('index still serves stale core asset version');else ok('index cache-busting is current'); if(!performance.includes('SunAttachmentGuard')||!performance.includes('MAX_SIDE=2048'))fail('chat photo compression guard missing');else ok('chat photo compression guard present'); if(!performance.includes("rpc('sun_dev_dashboard')")||!performance.includes('storage_size')||!performance.includes('server_size'))fail('Developer Console memory counters missing');else ok('Developer Console memory counters present'); if(performance.includes('records.forEach(r=>r.addedNodes.forEach(n=>{if(n.nodeType===1)scan(n)}));enhanceDeveloperMemory()'))fail('Developer Console memory refresh is still coupled to MutationObserver');else ok('Developer Console memory refresh loop removed');