بيانات توضيحية · مثال مدرسة الأمل؛ ليست بيانات حسابك أو أسعارًا حية.
دخول مالك التطبيق
سجّل الدخول بحساب Supabase الذي اعتمدته مديرًا من SQL Editor. لا يمنح إنشاء حساب أو استخدام Google صلاحية المدير تلقائيًا.
مرحبًا بعودتك
حساب مستقل وبيانات مالية خاصة بك، محفوظة في السحابة.
هذه الجلسة تستخدم Supabase. الحساب القديم لا يُربط بالبريد تلقائيًا؛ راجع المالك لاعتماد انتقال هويتك ونسخة بياناتك.
عزل البيانات يتم على الخادم حسب جلسة حسابك، وليس عبر روابط أو معرفات في المتصفح.
حزمة إعداد Supabase والـSQL الكامل
الملفات التالية مطابقة لملفات المشروع المعدّلة، لا مقتطفات. توقّف قبل تشغيل SQL أو نقل بيانات حقيقية حتى تعتمد النسخ الاحتياطية وخطة انتقال هوية الحسابات. لم تُنفّذ قاعدة البيانات من هذه الشاشة.
عرض ملف SQL الجديد كاملًا
-- ADDITIVE ONLY. Run only after owner approval and a database/source backup.
-- Does not modify finance_members / finance_records or migrate real records.
-- Supabase Auth UUIDs are NOT inferred from legacy email addresses.
create table public.finance_v2_members (
id bigint generated always as identity primary key,
user_id uuid not null unique references auth.users(id) default auth.uid(),
created_at timestamptz not null default now(),
status text not null default 'pending' check (status in ('pending','approved','rejected','suspended')),
is_admin boolean not null default false,
legacy_user_id text unique,
sequence bigint not null default 0 check (sequence between 0 and 9007199254740991),
reviewed_at timestamptz,
reviewed_by uuid references auth.users(id)
);
alter table public.finance_v2_members enable row level security;
grant select, insert, update, delete on public.finance_v2_members to authenticated;
create index finance_v2_members_user on public.finance_v2_members(user_id);
create index finance_v2_members_status on public.finance_v2_members(status,created_at);
create index finance_v2_members_reviewed_by on public.finance_v2_members(reviewed_by);
create function public.finance_v2_admin() returns boolean language sql stable security definer set search_path = '' as $$
select exists(select 1 from public.finance_v2_members where user_id=(select auth.uid()) and is_admin and status='approved');
$$;
create function public.finance_v2_approved() returns boolean language sql stable security definer set search_path = '' as $$
select exists(select 1 from public.finance_v2_members where user_id=(select auth.uid()) and status='approved');
$$;
revoke all on function public.finance_v2_admin(), public.finance_v2_approved() from public, anon;
grant execute on function public.finance_v2_admin() to authenticated;
-- Anon may evaluate the boolean helper in restrictive storage policies; with no UUID it is false.
grant execute on function public.finance_v2_approved() to anon,authenticated;
create policy finance_v2_member_read on public.finance_v2_members for select to authenticated using ((select auth.uid())=user_id or (select public.finance_v2_admin()));
-- No browser INSERT/UPDATE/DELETE policies on members: approval/admin/mapping are server decisions.
create table public.finance_v2_records (
id bigint generated always as identity primary key,
user_id uuid not null references auth.users(id) default auth.uid(),
created_at timestamptz not null default now(),
collection text not null check(collection in ('initiative','contact','transaction','settings')),
record_id text not null,
data jsonb not null check(jsonb_typeof(data)='object'),
updated_at timestamptz not null default now(),
original_amount numeric generated always as (case when collection='transaction' then (data->>'amount')::numeric end) stored,
exchange_rate numeric generated always as (case when collection='transaction' then (data->>'rate')::numeric end) stored,
reference_amount numeric generated always as (case when collection='transaction' then (data->>'referenceAmount')::numeric end) stored,
sar_amount numeric generated always as (case when collection='transaction' then (data->>'sarAmount')::numeric end) stored,
initiative_collection text generated always as (case when collection='transaction' then 'initiative' end) stored,
initiative_id text generated always as (case when collection='transaction' then nullif(data->>'initiativeId','') end) stored,
contact_collection text generated always as (case when collection='transaction' and nullif(data->>'contactId','') is not null then 'contact' end) stored,
contact_id text generated always as (case when collection='transaction' then nullif(data->>'contactId','') end) stored,
unique(user_id,collection,record_id),
foreign key(user_id,initiative_collection,initiative_id) references public.finance_v2_records(user_id,collection,record_id) deferrable initially deferred,
foreign key(user_id,contact_collection,contact_id) references public.finance_v2_records(user_id,collection,record_id) deferrable initially deferred
);
alter table public.finance_v2_records enable row level security;
grant select, insert, update, delete on public.finance_v2_records to authenticated;
create policy finance_v2_record_read on public.finance_v2_records for select to authenticated using ((select auth.uid())=user_id and (select public.finance_v2_approved()));
-- Mutations go through the checked RPC, not client-editable owner/status fields.
create index finance_v2_records_user on public.finance_v2_records(user_id);
create index finance_v2_records_initiative on public.finance_v2_records(user_id,initiative_id);
create index finance_v2_records_contact on public.finance_v2_records(user_id,contact_id);
create table public.finance_v2_imports (
id bigint generated always as identity primary key,
user_id uuid not null references auth.users(id) default auth.uid(),
created_at timestamptz not null default now(),
snapshot_id text not null check(snapshot_id ~ '^[a-f0-9]{64}$'),
source_user_id text not null,
manifest jsonb not null,
source_sequence bigint not null check(source_sequence between 0 and 9007199254740991),
complete boolean not null default false,
report jsonb,
unique(user_id,snapshot_id)
);
alter table public.finance_v2_imports enable row level security;
grant select, insert, update, delete on public.finance_v2_imports to authenticated;
create policy finance_v2_import_read on public.finance_v2_imports for select to authenticated using ((select auth.uid())=user_id and (select public.finance_v2_approved()));
create index finance_v2_imports_user on public.finance_v2_imports(user_id);
create index finance_v2_imports_source on public.finance_v2_imports(source_user_id);
create table public.finance_v2_stage (
id bigint generated always as identity primary key,
user_id uuid not null references auth.users(id) default auth.uid(),
created_at timestamptz not null default now(),
snapshot_id text not null,
ordinal integer not null check(ordinal between 0 and 9999),
entry jsonb not null,
unique(user_id,snapshot_id,ordinal),
foreign key(user_id,snapshot_id) references public.finance_v2_imports(user_id,snapshot_id)
);
alter table public.finance_v2_stage enable row level security;
grant select, insert, update, delete on public.finance_v2_stage to authenticated;
create policy finance_v2_stage_read on public.finance_v2_stage for select to authenticated using ((select auth.uid())=user_id and (select public.finance_v2_approved()));
create index finance_v2_stage_user on public.finance_v2_stage(user_id);
create index finance_v2_stage_snapshot on public.finance_v2_stage(user_id,snapshot_id);
-- No anonymous access or direct mutation policies on financial/import tables.
revoke all on public.finance_v2_members, public.finance_v2_records, public.finance_v2_imports, public.finance_v2_stage from anon, public;
create function public.finance_v2_validate(col text, rec jsonb) returns void language plpgsql set search_path = '' as $$
declare f text; a jsonb;
begin
if col is null or col not in ('initiative','contact','transaction','settings') or jsonb_typeof(rec) is distinct from 'object' or coalesce(rec->>'id','')='' or left(rec->>'id',5)='demo-' then raise exception 'سجل غير صالح'; end if;
if octet_length(rec::text)>200000 then raise exception 'حجم السجل يتجاوز 200 KB'; end if;
if col in ('initiative','contact') and coalesce(trim(rec->>'name'),'')='' then raise exception 'اسم السجل مطلوب'; end if;
if col='settings' and (rec->>'id'<>'settings' or jsonb_typeof(rec->'categories') is distinct from 'array' or jsonb_typeof(rec->'currencies') is distinct from 'array') then raise exception 'إعدادات غير صالحة'; end if;
if col='initiative' then
if coalesce(rec->>'code','')='' or coalesce(rec->>'baseCurrency','') !~ '^[A-Z]{3}$' then raise exception 'كود المبادرة أو عملتها غير صالح'; end if;
foreach f in array array['budget','usd','yer'] loop
if coalesce(rec->>f,'') !~ '^\d+(\.\d+)?$' then raise exception 'قيمة % غير صالحة',f; end if;
end loop;
if (rec->>'usd')::numeric<=0 or (rec->>'yer')::numeric<=0 then raise exception 'سعر الصرف يجب أن يكون موجبًا'; end if;
end if;
if col='transaction' then
if coalesce(rec->>'kind','') not in ('income','expense','credit','debit') then raise exception 'نوع عملية غير صالح'; end if;
foreach f in array array['amount','rate','referenceAmount','sarAmount'] loop
if jsonb_typeof(rec->f) is distinct from 'string' or coalesce(rec->>f,'') !~ '^\d+(\.\d+)?$' then raise exception 'قيمة % غير صالحة',f; end if;
end loop;
if (rec->>'amount')::numeric<=0 or (rec->>'rate')::numeric<=0 then raise exception 'مبلغ أو سعر غير موجب'; end if;
if round((rec->>'amount')::numeric*(rec->>'rate')::numeric,2)<>(rec->>'referenceAmount')::numeric then raise exception 'القيمة المرجعية لا تطابق السعر التاريخي'; end if;
if rec ? 'sarRate' then
if coalesce(rec->>'sarRate','') !~ '^\d+(\.\d+)?$' or (rec->>'sarRate')::numeric<=0 then raise exception 'سعر SAR غير صالح'; end if;
if round((rec->>'referenceAmount')::numeric*(rec->>'sarRate')::numeric,2)<>(rec->>'sarAmount')::numeric then raise exception 'قيمة SAR التاريخية غير متطابقة'; end if;
end if;
if coalesce(rec->>'currency','') !~ '^[A-Z]{3}$' or coalesce(rec->>'baseCurrency','') !~ '^[A-Z]{3}$' then raise exception 'عملة غير صالحة'; end if;
if coalesce(rec->>'date','') !~ '^\d{4}-\d{2}-\d{2}$' or to_char((rec->>'date')::date,'YYYY-MM-DD')<>rec->>'date' then raise exception 'تاريخ غير صالح'; end if;
if coalesce(rec->>'initiativeId','')='' then raise exception 'المبادرة مطلوبة'; end if;
if rec->>'kind' in ('credit','debit') and coalesce(rec->>'contactId','')='' then raise exception 'حساب له/عليه مطلوب'; end if;
if jsonb_typeof(rec->'attachments') is distinct from 'array' then raise exception 'المرفقات غير صالحة'; end if;
for a in select value from jsonb_array_elements(rec->'attachments') loop
if coalesce(a->>'url','')='' or coalesce(a->>'key','')='' or coalesce(a->>'iv','')='' or coalesce(a->>'originalName','')='' then raise exception 'مفتاح أو رابط مرفق مفقود'; end if;
end loop;
end if;
end;
$$;
revoke all on function public.finance_v2_validate(text,jsonb) from public,anon,authenticated;
create function public.finance_v2_record_guard() returns trigger language plpgsql set search_path = '' as $$
begin
perform public.finance_v2_validate(new.collection,new.data);
if new.record_id<>new.data->>'id' then raise exception 'معرّف غير متطابق'; end if;
if tg_op='UPDATE' and (new.user_id<>old.user_id or new.collection<>old.collection or new.record_id<>old.record_id) then raise exception 'لا يمكن تغيير ملكية السجل'; end if;
if tg_op='UPDATE' and new.collection='initiative' and new.data->>'baseCurrency' is distinct from old.data->>'baseCurrency' and exists(select 1 from public.finance_v2_records where user_id=new.user_id and initiative_id=new.record_id) then raise exception 'لا يمكن تغيير عملة مبادرة لها عمليات'; end if;
return new;
end;
$$;
create trigger finance_v2_record_guard before insert or update on public.finance_v2_records for each row execute function public.finance_v2_record_guard();
revoke all on function public.finance_v2_record_guard() from public,anon,authenticated;
create function public.finance_v2_command(p_action text,p_payload jsonb default '{}'::jsonb) returns jsonb language plpgsql security definer set search_path = '' as $$
declare
uid uuid := auth.uid(); m public.finance_v2_members%rowtype; imp public.finance_v2_imports%rowtype;
rec jsonb; item jsonb; col text; rid text; result jsonb; issues jsonb := '[]'::jsonb;
count_rows bigint; expected bigint; seq bigint; ord integer; snapshot text;
counts jsonb; actual_totals jsonb; pair record; total numeric; target uuid;
begin
if uid is null or auth.role()<>'authenticated' then raise exception 'سجّل الدخول أولًا'; end if;
insert into public.finance_v2_members(user_id) values(uid) on conflict(user_id) do nothing;
select * into m from public.finance_v2_members where user_id=uid for update;
if p_action='status' then return jsonb_build_object('status',m.status,'isOwner',m.is_admin,'configured',true); end if;
if p_action in ('members','review') then
if not m.is_admin or m.status<>'approved' then raise exception 'الوصول مرفوض: الإدارة العامة للمدير المعتمد فقط'; end if;
if p_action='review' then
target := (p_payload->>'userId')::uuid;
if coalesce(p_payload->>'status','') not in ('approved','rejected','suspended') then raise exception 'قرار غير صالح'; end if;
if target=uid or exists(select 1 from public.finance_v2_members where user_id=target and is_admin) then raise exception 'لا يمكن تعليق المدير'; end if;
update public.finance_v2_members set status=p_payload->>'status',reviewed_at=now(),reviewed_by=uid where user_id=target;
if not found then raise exception 'الحساب غير موجود'; end if;
return jsonb_build_object('saved',true);
end if;
select count(*) into count_rows from public.finance_v2_members where not is_admin and (coalesce(p_payload->>'status','pending')='' or status=coalesce(p_payload->>'status','pending'));
select coalesce(jsonb_agg(to_jsonb(x)),'[]'::jsonb) into result from (
select fm.user_id,coalesce(au.email,'') email,coalesce(au.raw_user_meta_data->>'name','') name,fm.status,fm.created_at,fm.reviewed_at
from public.finance_v2_members fm join auth.users au on au.id=fm.user_id where not fm.is_admin and (coalesce(p_payload->>'status','pending')='' or fm.status=coalesce(p_payload->>'status','pending'))
order by fm.created_at desc,fm.user_id limit 20 offset ((greatest(1,least(100000,coalesce((p_payload->>'page')::integer,1)))-1)*20)
) x;
return jsonb_build_object('members',result,'total',count_rows);
end if;
if m.status<>'approved' then raise exception 'حسابك غير معتمد أو معلّق'; end if;
if p_action='load' then
select coalesce(jsonb_agg(jsonb_build_object('key','finance:'||collection||case when collection='settings' then '' else ':'||record_id end,'value',data) order by collection,record_id),'[]'::jsonb) into result from public.finance_v2_records where user_id=uid;
return jsonb_build_object('data',result,'sequence',m.sequence::text);
elsif p_action='sequence' then
update public.finance_v2_members set sequence=sequence+1 where user_id=uid returning sequence into seq;
return to_jsonb(seq::text);
elsif p_action='save' then
col:=p_payload->>'collection'; rec:=p_payload->'record'; rid:=rec->>'id';
perform public.finance_v2_validate(col,rec);
if col='transaction' then
if not exists(select 1 from public.finance_v2_records where user_id=uid and collection='initiative' and record_id=rec->>'initiativeId') then raise exception 'المبادرة لا تنتمي لحسابك'; end if;
if coalesce(rec->>'contactId','')<>'' and not exists(select 1 from public.finance_v2_records where user_id=uid and collection='contact' and record_id=rec->>'contactId') then raise exception 'الجهة لا تنتمي لحسابك'; end if;
end if;
insert into public.finance_v2_records(user_id,collection,record_id,data) values(uid,col,rid,rec) on conflict(user_id,collection,record_id) do update set data=excluded.data,updated_at=now();
return jsonb_build_object('saved',true);
end if;
if p_action not in ('migration_begin','migration_stage','migration_check','migration_finish') then raise exception 'إجراء غير مسموح'; end if;
snapshot:=p_payload->>'snapshotId';
if p_action='migration_begin' then
if m.legacy_user_id is null or m.legacy_user_id is distinct from p_payload->>'sourceUserId' then raise exception 'يجب أن يعتمد المالك ربط هوية المصدر من SQL Editor أولًا'; end if;
seq:=(p_payload->>'sequence')::bigint;
expected:=coalesce((p_payload->'manifest'->'counts'->>'initiative')::bigint,0)+coalesce((p_payload->'manifest'->'counts'->>'contact')::bigint,0)+coalesce((p_payload->'manifest'->'counts'->>'transaction')::bigint,0)+coalesce((p_payload->'manifest'->'counts'->>'settings')::bigint,0);
if expected<1 or expected>10000 or jsonb_typeof(p_payload->'manifest'->'totals') is distinct from 'object' then raise exception 'بيان النسخة غير صالح'; end if;
insert into public.finance_v2_imports(user_id,snapshot_id,source_user_id,manifest,source_sequence) values(uid,snapshot,m.legacy_user_id,p_payload->'manifest',seq) on conflict(user_id,snapshot_id) do nothing;
select * into imp from public.finance_v2_imports where user_id=uid and snapshot_id=snapshot;
if imp.manifest is distinct from p_payload->'manifest' or imp.source_sequence<>seq or imp.source_user_id<>m.legacy_user_id then raise exception 'تعارض بيان النسخة؛ لم يستبدل البيان السابق'; end if;
return jsonb_build_object('complete',imp.complete);
end if;
select * into imp from public.finance_v2_imports where user_id=uid and snapshot_id=snapshot for update;
if not found then raise exception 'جلسة الترحيل غير موجودة'; end if;
if m.legacy_user_id is distinct from imp.source_user_id then raise exception 'ربط هوية المصدر تغيّر؛ راجع المالك'; end if;
if imp.complete then return imp.report; end if;
if p_action='migration_stage' then
if jsonb_typeof(p_payload->'entries') is distinct from 'array' or jsonb_array_length(p_payload->'entries')>25 then raise exception 'دفعة غير صالحة'; end if;
for item in select value from jsonb_array_elements(p_payload->'entries') loop
ord:=(item->>'ordinal')::integer;
rec:=item-'ordinal';
insert into public.finance_v2_stage(user_id,snapshot_id,ordinal,entry) values(uid,snapshot,ord,rec) on conflict(user_id,snapshot_id,ordinal) do nothing;
if exists(select 1 from public.finance_v2_stage where user_id=uid and snapshot_id=snapshot and ordinal=ord and entry is distinct from rec) then raise exception 'تعارض سجل مرحلي؛ لم يستبدل المصدر السابق'; end if;
end loop;
return jsonb_build_object('staged',true);
end if;
expected:=coalesce((imp.manifest->'counts'->>'initiative')::bigint,0)+coalesce((imp.manifest->'counts'->>'contact')::bigint,0)+coalesce((imp.manifest->'counts'->>'transaction')::bigint,0)+coalesce((imp.manifest->'counts'->>'settings')::bigint,0);
select count(*) into count_rows from public.finance_v2_stage where user_id=uid and snapshot_id=snapshot;
if count_rows<>expected then issues:=issues||jsonb_build_array('عدد السجلات لا يطابق النسخة؛ استأنف تجهيز الدفعات'); end if;
for item in select entry from public.finance_v2_stage where user_id=uid and snapshot_id=snapshot order by ordinal loop
col:=item->>'collection'; rec:=item->'record'; rid:=rec->>'id';
begin
perform public.finance_v2_validate(col,rec);
if exists(select 1 from public.finance_v2_stage where user_id=uid and snapshot_id=snapshot and entry->>'collection'=col and entry->'record'->>'id'=rid group by entry->>'collection',entry->'record'->>'id' having count(*)>1) then raise exception 'معرّف مكرر'; end if;
if exists(select 1 from public.finance_v2_records where user_id=uid and collection=col and record_id=rid and data is distinct from rec) then raise exception 'تعارض مع سجل محفوظ؛ لم يتم الاستبدال'; end if;
if col='transaction' then
if not exists(select 1 from public.finance_v2_stage where user_id=uid and snapshot_id=snapshot and entry->>'collection'='initiative' and entry->'record'->>'id'=rec->>'initiativeId') then raise exception 'المبادرة مفقودة من النسخة'; end if;
if coalesce(rec->>'contactId','')<>'' and not exists(select 1 from public.finance_v2_stage where user_id=uid and snapshot_id=snapshot and entry->>'collection'='contact' and entry->'record'->>'id'=rec->>'contactId') then raise exception 'الجهة مفقودة من النسخة'; end if;
if coalesce(substring(rec->>'receipt' from '-([0-9]+)$')::numeric,0)>imp.source_sequence then raise exception 'تسلسل المصدر أقل من رقم سند محفوظ'; end if;
end if;
exception when others then issues:=issues||jsonb_build_array(coalesce(col,'?')||':'||coalesce(rid,'?')||' — '||sqlerrm); end;
end loop;
if jsonb_array_length(issues)=0 then
select jsonb_build_object(
'initiative',count(*) filter(where entry->>'collection'='initiative'),
'contact',count(*) filter(where entry->>'collection'='contact'),
'transaction',count(*) filter(where entry->>'collection'='transaction'),
'settings',count(*) filter(where entry->>'collection'='settings'),
'archived',count(*) filter(where coalesce(entry->'record'->>'deletedAt','')<>''),
'attachments',coalesce(sum(case when entry->>'collection'='transaction' then jsonb_array_length(entry->'record'->'attachments') else 0 end),0)) into counts
from public.finance_v2_stage where user_id=uid and snapshot_id=snapshot;
if counts is distinct from imp.manifest->'counts' then issues:=issues||jsonb_build_array('أعداد الأنواع أو الأرشيف أو المرفقات لا تطابق المصدر'); end if;
select coalesce(jsonb_object_agg(k,v),'{}'::jsonb) into actual_totals from (
select prefix||':'||currency||':'||(s.entry->'record'->>'kind')||':'||scope k,sum(amount::numeric)::text v
from public.finance_v2_stage s cross join lateral (values
('sar','SAR',s.entry->'record'->>'sarAmount'),
('original',s.entry->'record'->>'currency',s.entry->'record'->>'amount'),
('reference',s.entry->'record'->>'baseCurrency',s.entry->'record'->>'referenceAmount')) a(prefix,currency,amount)
cross join (values('all'),('active')) b(scope)
where s.user_id=uid and s.snapshot_id=snapshot and s.entry->>'collection'='transaction' and (scope='all' or coalesce(s.entry->'record'->>'deletedAt','')='')
group by prefix,currency,s.entry->'record'->>'kind',scope
) sums;
if (select count(*) from jsonb_object_keys(actual_totals))<>(select count(*) from jsonb_object_keys(imp.manifest->'totals')) then issues:=issues||jsonb_build_array('مجموعات العملات لا تطابق المصدر'); end if;
for pair in select key,value from jsonb_each_text(actual_totals) loop
if not (imp.manifest->'totals' ? pair.key) or (imp.manifest->'totals'->>pair.key)::numeric<>pair.value::numeric then issues:=issues||jsonb_build_array('المجموع لا يطابق المصدر: '||pair.key); end if;
end loop;
end if;
result:=jsonb_build_object('complete',false,'staged',count_rows,'expected',expected,'issues',issues,'manifest',jsonb_build_object('counts',counts,'totals',actual_totals));
if p_action='migration_finish' and jsonb_array_length(issues)=0 then
-- One atomic RPC: stage verification, conflict checks, copy, read-back existence, sequence and marker.
insert into public.finance_v2_records(user_id,collection,record_id,data)
select uid,entry->>'collection',entry->'record'->>'id',entry->'record' from public.finance_v2_stage where user_id=uid and snapshot_id=snapshot
order by case entry->>'collection' when 'initiative' then 0 when 'contact' then 1 when 'settings' then 2 else 3 end
on conflict(user_id,collection,record_id) do nothing;
if exists(select 1 from public.finance_v2_stage s where s.user_id=uid and s.snapshot_id=snapshot and not exists(select 1 from public.finance_v2_records r where r.user_id=uid and r.collection=s.entry->>'collection' and r.record_id=s.entry->'record'->>'id')) then raise exception 'التحقق من وجود السجلات المنقولة فشل'; end if;
update public.finance_v2_members set sequence=greatest(sequence,imp.source_sequence) where user_id=uid;
result:=jsonb_set(result,'{complete}','true'::jsonb);
update public.finance_v2_imports set complete=true,report=result where user_id=uid and snapshot_id=snapshot;
else
update public.finance_v2_imports set report=result where user_id=uid and snapshot_id=snapshot;
end if;
return result;
end;
$$;
revoke all on function public.finance_v2_command(text,jsonb) from public,anon;
grant execute on function public.finance_v2_command(text,jsonb) to authenticated;
-- New buckets only; do not alter or delete existing storage objects.
do $$ begin
if exists(select 1 from storage.buckets where id='finance-private' and public) then raise exception 'تعارض حاوية: finance-private موجودة وعامة؛ توقف للمراجعة دون تعديلها'; end if;
if exists(select 1 from storage.buckets where id='finance-logos' and not public) then raise exception 'تعارض حاوية: finance-logos موجودة وخاصة؛ توقف للمراجعة دون تعديلها'; end if;
end; $$;
insert into storage.buckets(id,name,public,file_size_limit,allowed_mime_types)
values ('finance-private','finance-private',false,15000000,array['text/plain'])
on conflict(id) do nothing;
insert into storage.buckets(id,name,public,file_size_limit,allowed_mime_types)
values ('finance-logos','finance-logos',true,5242880,array['image/png','image/jpeg','image/webp','image/svg+xml'])
on conflict(id) do nothing;
-- Restrictive guards prevent unrelated permissive storage policies from opening these buckets.
create policy finance_v2_storage_read_guard on storage.objects as restrictive for select to anon,authenticated
using (bucket_id<>'finance-private' or ((storage.foldername(name))[1]=(select auth.uid())::text and (select public.finance_v2_approved())));
create policy finance_v2_storage_insert_guard on storage.objects as restrictive for insert to authenticated
with check (bucket_id not in ('finance-private','finance-logos') or ((storage.foldername(name))[1]=(select auth.uid())::text and (select public.finance_v2_approved())));
create policy finance_v2_storage_update_guard on storage.objects as restrictive for update to authenticated
using (bucket_id not in ('finance-private','finance-logos') or ((storage.foldername(name))[1]=(select auth.uid())::text and (select public.finance_v2_approved())))
with check (bucket_id not in ('finance-private','finance-logos') or ((storage.foldername(name))[1]=(select auth.uid())::text and (select public.finance_v2_approved())));
create policy finance_v2_storage_delete_guard on storage.objects as restrictive for delete to authenticated
using (bucket_id not in ('finance-private','finance-logos') or ((storage.foldername(name))[1]=(select auth.uid())::text and (select public.finance_v2_approved())));
create policy finance_v2_storage_anon_insert_guard on storage.objects as restrictive for insert to anon
with check (bucket_id not in ('finance-private','finance-logos'));
create policy finance_v2_storage_anon_update_guard on storage.objects as restrictive for update to anon
using (bucket_id not in ('finance-private','finance-logos'))
with check (bucket_id not in ('finance-private','finance-logos'));
create policy finance_v2_storage_anon_delete_guard on storage.objects as restrictive for delete to anon
using (bucket_id not in ('finance-private','finance-logos'));
create policy finance_v2_private_read on storage.objects for select to authenticated
using (bucket_id='finance-private' and (storage.foldername(name))[1]=(select auth.uid())::text and (select public.finance_v2_approved()));
create policy finance_v2_logo_read on storage.objects for select to anon,authenticated using (bucket_id='finance-logos');
create policy finance_v2_file_insert on storage.objects for insert to authenticated
with check (bucket_id in ('finance-private','finance-logos') and (storage.foldername(name))[1]=(select auth.uid())::text and (select public.finance_v2_approved()));
create policy finance_v2_file_update on storage.objects for update to authenticated
using (bucket_id in ('finance-private','finance-logos') and (storage.foldername(name))[1]=(select auth.uid())::text and (select public.finance_v2_approved()))
with check (bucket_id in ('finance-private','finance-logos') and (storage.foldername(name))[1]=(select auth.uid())::text and (select public.finance_v2_approved()));
create policy finance_v2_file_delete on storage.objects for delete to authenticated
using (bucket_id in ('finance-private','finance-logos') and (storage.foldername(name))[1]=(select auth.uid())::text and (select public.finance_v2_approved()));
عرض تعليمات الانتقال والإعداد كاملة
# إصلاح Supabase وخطة الانتقال — لا تنفّذ على بيانات حقيقية قبل الموافقة
## نتيجة فحص البيئة والكود
- التكامل المفعّل في إعدادات هذا المشروع هو Supabase، بمشروع `bhsmgovawunshzbcfozn`. تعليماته تستبدل GenMB Auth وDatabase؛ لذلك لا يجوز الاعتماد على وجود `window.genmb.auth` أو KV الخاص بالمستخدم القديم. وجود تعريف TypeScript لا يثبت دعم واجهة وقت التشغيل.
- لا توجد أداة في هذه الجلسة لاستكشاف حسابات الإنتاج أو تصدير كلمات المرور/معرّفات GenMB أو تنفيذ SQL. لم تُختبر جلسة حقيقية أو شبكة Supabase، ولم تُرحّل أموال أو جداول.
- حُفظت تجربة البريد وكلمة المرور والتأكيد بالرمز والاستعادة، لكن تنفيذها الآن عبر Supabase Auth الذي تدعمه البيئة. لا ندّعي أن كلمة مرور GenMB تعمل في Supabase. **لا تنشر الانتقال للمستخدمين القدامى قبل اعتماد خطة الهوية والنسخ أدناه.**
- إذا أردت إبقاء مزود GenMB Auth نفسه، اطلب من دعم المنصة مسار مصادقة خارجي مدعوم مع هذا التكامل أو استعد إعداد التكامل السابق في نسخة منفصلة. لا توجد طريقة موثوقة موثقة هنا لإعادة استعمال جلسة GenMB أو تحويلها إلى Supabase JWT؛ لم ننشئ بديلًا وهميًا لها.
## الملفات الفعلية
1. `src/lib/supabase.ts`: العميل الوحيد، Project URL والمفتاح publishable المرفقان بإعداد التكامل.
2. `src/lib/access.ts`: استدعاء RPC `finance_v2_command` وفحص الأخطاء.
3. `src/store.ts`: جلسات Supabase، مساحات معزولة، حفظ وتحميل وترقيم؛ لا نقل تلقائي ولا fallback إلى KV.
4. `src/pages/Auth.tsx`: بريد/كلمة مرور، تسجيل، OTP، استعادة OTP أو رابط، خروج.
5. `src/pages/Administration.tsx`: قرارات الموافقة والرفض والتعليق؛ الدور مصدره SQL.
6. `supabase/migrations/202610180002_finance_supabase_auth.sql`: الجداول والسياسات والدوال والحاويات الجديدة كاملة. لا تشغّل ملف 001 القديم على أنه إعداد الإصلاح.
7. `functions/supabaseBridge.ts`, `functions/financeService.ts`, `functions/resolveRole.ts`: مسار توافق اختياري يمرر رمز Supabase إلى SQL دون مفتاح خدمة ودون الثقة في ctx من مزود آخر. الواجهة لا تحتاجه للتشغيل.
8. `src/lib/migration.ts`, `src/components/LegacyMigration.tsx`: معاينة نسخة JSON، بصمة ثابتة، staging قابل للاستئناف، تقرير، موافقة منفصلة للنسخ النهائي.
9. `src/lib/attachments.ts`: مرفقات جديدة مشفرة في حاوية Supabase خاصة، مع دعم الروابط القديمة دون تغيير.
10. `src/components/DataTransfer.tsx`: الاستيراد الحالي باقٍ، والنسخة الجديدة تقرأ السجلات وتسلسل السندات من الخادم.
11. `src/components/IntegrationSetup.tsx`, `src/types/raw.d.ts`: عرض SQL كاملًا وتنزيل ملفات الإعداد من شاشة الإدارة حتى قبل تعيين المدير.
12. `tests/supabase-isolation.sql`, `tests/supabase-integration.md`: اختبارات عزل وصلاحيات قابلة للتنفيذ في مشروع اختبار، وسيناريوهات فشل الشبكة والاستئناف. فحوص المال والمعاينة المحلية من الإعدادات.
## قبل النشر — موافقتك مطلوبة
1. احتفظ بنسخة كاملة من ملفات المشروع الحالية وإعدادات التكامل، وصورة من إعدادات المصادقة، ونسخة قاعدة Supabase الحالية حسب آلية النسخ المتاحة لديك.
2. أوقف الكتابة في المصدر القديم عند موعد الانتقال المتفق عليه، لا قبل أخذ النسخة.
3. صدّر **لكل حساب** جميع مفاتيح `finance:*` بما فيها الأرشيف والإعدادات و`finance:sequence`، مع معرّف المستخدم الأصلي من مصدر موثوق. استخدم تصدير المنصة/الدعم أو نسخة التطبيق القديمة المتصلة بمزودها، لا تستنتج المعرّف من البريد.
4. لا تضغط الاستيراد العام لترحيل المصدر القديم؛ استخدم أداة النقل المخصصة. الاستيراد العام يحتفظ بوظيفته السابقة في إضافة/تحديث سجلات الحساب الحالي، وليس أداة إثبات هوية.
5. النسخة المالية المتوافقة: `{ "version":1, "records":[{"collection":"initiative", "record":{...}}, ...], "sequence":"آخر رقم", "sourceUserId":"المعرف الأصلي" }`. يشمل records المبادرات والجهات والعمليات والأرشيف والإعدادات. إذا كان تصدير المنصة raw KV، حوّله إلى هذه البنية في نسخة عمل منفصلة دون تعديل المصدر أو إسقاط المفاتيح غير المعروفة؛ أدرجها في تقرير مراجعة منفصل.
6. نزّل أيضًا الملفات المشفرة الأصلية والشعارات. JSON وحده لا يحتوي بايتات الملفات، ولكنه يحتوي مفاتيح فك التشفير؛ خزّنه مشفرًا وبسرية.
7. راجع عدد كل نوع، الأرشيف والمرفقات، والمجاميع حسب نوع العملية والعملات الأصلية والمرجعية وSAR. لا تعتمد ملفًا ناقصًا أو كشفًا مفلترًا كنسخة كاملة.
## خطة هوية الحسابات (يدوية موثوقة وليست ربطًا بالبريد)
- يحتفظ المصدر بـGenMB ID الخاص به؛ Supabase ينشئ UUID مستقلًا. لم تُغيّر معرفات المبادرات والعمليات والجهات.
- يجب التحقق من ملكية الحساب القديم من لوحة المالك أو إثبات تسجيل دخول للمصدر، والتحقق من حساب Supabase الجديد مستقلًا. استخدام نفس البريد وحده ليس دليلًا كافيًا للربط.
- يسجّل صاحب الحساب في Supabase ويؤكد بريده، أو يستعيد حساب Supabase موجودًا. كلمة المرور القديمة لا تُنسخ ولا تُخزّن في المشروع. إذا أردت انتقالًا يحفظ كلمات المرور نفسها، توقّف واطلب مسار تصدير/استيراد رسمي مدعوم من المزودين؛ لم يُنفذ ذلك هنا.
- بعد إثبات الهويتين يضع مالك قاعدة البيانات الربط `legacy_user_id` بواسطة SQL Editor فقط. لا يستطيع المتصفح تعيين هذا الحقل أو is_admin أو status.
## إعداد المشروع (في مشروع اختبار أولًا)
1. من لوحة Supabase، افتح **Connect** للحصول على Project URL؛ يمكن أيضًا مراجعته في Project Settings → Data API/API حسب واجهة اللوحة. قارنه بالإعداد المفعّل في Database لدى GenMB. المشروع المحدد بالفعل هو مشروعك المرفق في التكامل؛ لا توجد عناوين `your-project` في الجسر الحالي.
2. لا تضف `SUPABASE_SERVICE_ROLE_KEY`: الإصلاح لا يحتاجه. العميل يستخدم المفتاح publishable العام، والخادم/SQL يعملان بصلاحيات جلسة Supabase. لا تضع أي service_role أو sb_secret في ملفات المشروع أو VITE أو الواجهة. يمكن إزالة Secret القديم غير المستخدم من لوحة GenMB بعد مراجعة عدم استخدام تطبيقات أخرى له.
3. إدارة الأسرار الخادمية التي تدعمها GenMB هي `secrets.get(name)` من `./genmb.ts` داخل Functions. لم نستعملها هنا لأن المسار المعتمد لا يحتاج مفتاح خدمة. لم تُنشأ أسرار داخلية ولا نموذج مفاتيح داخل التطبيق.
4. بعد موافقتك فقط، نفّذ **الملف 002 كاملًا مرة واحدة** في Supabase SQL Editor، أو اطلب تطبيقه من Database بعد اعتمادك. ينشئ أسماء v2 جديدة، لا يحذف الجداول القديمة ولا يعيد تهيئتها. لا يحتوي BEGIN/COMMIT أو VACUUM أو DROP/TRUNCATE. لا تعِد تشغيله بعد نجاحه؛ إذا نجح جزء منه فقط، راجع قائمة الكائنات قبل استكمال الباقي ولا تحذف بيانات.
5. فعّل تسجيل البريد/كلمة المرور وتأكيد البريد في Supabase Authentication. في Email Templates → Confirm signup وReset password أضف `{{ .Token }}` لعرض رمز الدخول، مع إبقاء رابط التأكيد `{{ .ConfirmationURL }}` إذا أردت الخيارين. اضبط طول OTP على 6 أرقام لمطابقة الحقل، وأعد اختبار صلاحية الرسالة والرمز. إرسال البريد الفعلي يحتاج إعدادات بريد مناسبة وحدود مزودك.
6. في Authentication → URL Configuration ضع رابط موقعك المنشور كـSite URL، وأضف أصل ومسار العودة الذي تستخدمه الواجهة إلى Redirect URLs. الواجهة تمرر `window.location.origin + window.location.pathname` دون اختراع endpoint. اختبر خارج iframe لتفادي تعارض OAuth أو روابط البريد مع المعاينة.
7. فعّل Google في Supabase فقط إذا ستستخدم زر Google الموجود؛ اضبط موفر Google وعنوان callback الذي يعرضه Supabase. لا يمنح Google أي دور تلقائيًا.
8. سجّل دخول حسابك الجديد وأكّد البريد وافتح الإدارة. في Authentication → Users انسخ **UUID** للحساب الذي أثبتّ ملكيته، ثم نفّذ من SQL Editor (استبدل النص بUUID الحقيقي، وليس بالبريد):
```sql
insert into public.finance_v2_members(user_id,status,is_admin)
values ('REPLACE_WITH_VERIFIED_OWNER_UUID'::uuid,'approved',true)
on conflict(user_id) do update set status='approved',is_admin=true;
```
9. لربط حساب قديم ثبتت ملكيته بحساب جديد، وبعد الموافقة على النقل:
```sql
update public.finance_v2_members
set legacy_user_id = 'VERIFIED_ORIGINAL_GENMB_USER_ID'
where user_id = 'VERIFIED_SUPABASE_USER_UUID'::uuid;
```
لا يسمح UI بهذا التعيين. تأكد من وجود العضو بعد أول دخول، ومن تحديث صف واحد فقط. المعرف القديم فريد؛ لا يُربط بحسابين.
10. انشر الملفات من GenMB بعد نجاح مشروع الاختبار، أعد الدخول، وافحص الحالة. ظهور واجهة سليمة وحده ليس اختبار اتصال؛ يتطلب النجاح رد status/load/save فعليًا. لا يُعرض Connected عند فشل RPC.
## إذا كان المصدر موجودًا بالفعل في جداول Supabase القديمة
لا تطبق UPDATE على user_id ولا تنسخ الحسابات بمجرد تطابق البريد. بعد إثبات هويتي الحساب من المالك، استخدم استعلام قراءة فقط لاستخراج نسخة المصدر لحساب واحد، واحفظ النتيجة JSON في ملف سري:
```sql
select jsonb_build_object(
'version',1,
'sourceUserId',m.user_id,
'sequence',m.sequence::text,
'records',coalesce((select jsonb_agg(jsonb_build_object('collection',r.collection,'record',r.data) order by r.collection,r.record_id)
from public.finance_records r where r.user_id=m.user_id),'[]'::jsonb)
)
from public.finance_members m
where m.user_id='VERIFIED_ORIGINAL_GENMB_USER_ID';
```
المخطط القديم ربما سبق أن حذف events أو صنّف بعض القيم؛ النسخة الناتجة تحفظ حالته الحالية كما هو. لا ندّعي استعادة معلومات حُذفت سابقًا.
## النقل بعد الموافقة فقط
1. الإعدادات → نقل نسخة الحساب القديم. أدخل المعرّف القديم المعتمد وتسلسل المصدر الحقيقي (يشمل الأرقام المحجوزة حتى إن لم تُحفظ عملية)، ثم اختر JSON الأصلي. اقرأ هذه القيم من النسخة المعتمدة؛ لا تخمّنها.
2. «معاينة وفحص دون كتابة»: أعداد ومجاميع، أسعار محفوظة، روابط ومفاتيح المرفقات، التاريخ، المعرفات المكررة والعلاقات. الأخطاء تُعرض ولا تُسقط السجلات بصمت.
3. بعد النسخة الاحتياطية وافق على التجهيز. تنقل دفعات إلى جداول staging فقط. نفس بصمة الملف ونفس الأرقام الترتيبية تجعل إعادة إرسال الدفعات آمنة؛ المحتوى المختلف في ordinal نفسه يُرفض، لا يُستبدل.
4. افصل الشبكة ثم اختر النسخة نفسها واستأنف. تحصل على تقرير محفوظ ومقروء مرة أخرى من SQL؛ لا تُحفظ حالة نجاح في localStorage.
5. الخادم يفحص كل سجل، العلاقة في النسخة، أعداد الأنواع والأرشيف والمرفقات، والمجاميع الأصلية/المرجعية/SAR لكل نوع، النشطة وجميع السجلات. المقارنة للأموال numeric وليست JSON/string بعد إعادة تسلسل PostgreSQL.
6. إذا تعارض ID مع سجل هدف مختلف، لا يُستبدل. أصلح **نسخة عمل** بموافقة صاحبها أو راجع التعارض خارج التطبيق؛ لا تعدل المصدر ولا تتجاوز الخطأ أو تحذف السجل. ملف العمل المعدّل له بصمة مختلفة؛ يحتفظ staging القديم بالتقرير.
7. نزّل التقرير، ثم وافق على النسخ النهائي. RPC واحد ينسخ السجلات بعد الفحص، ويتحقق من وجودها، ويحفظ أكبر تسلسل وعلامة complete ذريًا. عند فشل أي خطوة لا تُعتمد النتيجة. لا تُعاد تسعير العمليات ولا تُعدّل روابط قديمة ولا تُحذف نسخة المصدر.
8. بعد نجاح النقل قارن كشفًا كاملًا قبل/بعد وافتح جميع المرفقات المطلوبة. إعادة نفس النسخة المكتملة تعيد تقريرها ولا تعيد نسخ السجلات. لا تستعمل النسخة القديمة للكتابة بعد الانتقال دون خطة مصالحة جديدة.
## الأمن والحدود
- auth.uid من JWT Supabase يحدد المالك لكل سجل؛ لا تقبل الدالة p_user أو p_owner من المتصفح. بيانات المستخدم metadata للعرض فقط.
- جميع جداول v2 تفعّل RLS وGRANT قراءة للـauthenticated وفق نطاقه والموافقة. منح أفعال الجدول لا يتيحها دون policy: لا توجد سياسات تعديل مباشر للأعضاء أو المالية/staging، لأن الكتابة كلها داخل RPC Security Definer ثابت search_path يتحقق من UUID والموافقة. المدير يقرأ طلبات الحسابات فقط، لا أموال الآخرين.
- FK مركب يجمع UUID ونوع السجل ومعرفه؛ يمنع علاقات مبادرة/جهة من حساب آخر. SQL Numeric للحسابات، JSON يحفظ النصوص التاريخية كاملة. قفل صف العضو ينسّق المراجعة والكتابة والترحيل والترقيم.
- التعليق يمنع الطلب التالي والقراءة المباشرة والمرفقات الخاصة. لا يمحو بيانات سبق تحميلها من ذاكرة المتصفح، ولا يمكن تحويل روابط GenMB العامة القديمة إلى خاصة دون نقل مستقل. AES-GCM ومفاتيحها تبقى محفوظة.
- التخزين الجديد Supabase: finance-private خاصة للمرفقات المشفرة؛ finance-logos عامة للشعارات. كلاهما لا يغيّر روابط الملفات القديمة. الشعار يُرفع من الهاتف ثم تعتمد رابطه بحفظ الإعدادات.
- 10000 سجل/نسخة، 200 KB للسجل، 25 سجل/دفعة، JSON محلي حتى 20 MB. لا يُقبل قطع الملف على أنه كامل. إعادة إرسال دفعة بعد فشل الشبكة آمنة؛ RPC النهائي قد يحتاج تقسيمًا خادميًا جديدًا للحسابات الأكبر.
- الربط اليدوي يحفظ علاقة الهوية بعد تحقق المالك؛ لا يمثل مشاركة فريق/مؤسسة بين الحسابات، ولا ترحيل كلمات مرور، ولا سجل تدقيق محاسبي كامل.
## نتائج التنفيذ والاختبارات
- عُدّلت ملفات المشروع فعليًا. فحص التحرير المتاح أفاد عدم وجود أخطاء parsing؛ لم يقدم نتيجة تنفيذ SQL أو اتصال ولا تشغيل اختبارات حسابين.
- لم تتوفر في هذه الجلسة طرفية لتشغيل `npm run build` أو `supabase test db`. نفّذ `npm install` ثم `npm run build` محليًا؛ البناء يشغّل `tsc -b && vite build`.
- الاختبارات المحلية: الإعدادات → تشغيل الاختبارات الحسابية (فحوص المال ومعاينة النقل). هذا الزر ينفذها ويعرض النتائج الفعلية في المتصفح. لا تُعد نتائج مزعومة قبل الضغط عليه.
- الاختبارات الخادمية: راجع tests/supabase-isolation.sql وtests/supabase-integration.md. نفّذها في مشروع اختبار لا الإنتاج. لا توجد نتيجة اجتياز معلنة هنا.
- توقف الإصلاح قبل أي SQL حي أو ربط هويات أو نقل أموال. المطلوب منك الآن: الموافقة على خطة الانتقال والنسخ، تنفيذ migration في بيئة اختبار، اعتماد UUID المدير، واختبار الحسابين قبل النشر النهائي.
## مراجع رسمية
- Supabase Docs: JavaScript verifyOtp، Email Templates، resetPasswordForEmail، getSession/onAuthStateChange.
- Supabase Docs: Row Level Security، Securing your API (grants + policies)، Database Functions (Security Definer/search_path)، Storage Access Control.