snapshot of https://github.com/marmelab/atomic-crm.git (main, 2026-09-18; blobs > 131072B omitted)
Some checks failed
✅ Check / 🔬 ESLint (push) Failing after 21s
✅ Check / 🏷️ Typecheck (push) Failing after 11s
✅ Check / 🔎 Test (push) Failing after 11s
✅ Check / e2e-test (push) Failing after 11s
✅ Check / 🔨 Build (push) Failing after 11s
🚀 Deploy / 🚀 Deploy (doc) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (demo) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (supabase) (push) Failing after 18s
Some checks failed
✅ Check / 🔬 ESLint (push) Failing after 21s
✅ Check / 🏷️ Typecheck (push) Failing after 11s
✅ Check / 🔎 Test (push) Failing after 11s
✅ Check / e2e-test (push) Failing after 11s
✅ Check / 🔨 Build (push) Failing after 11s
🚀 Deploy / 🚀 Deploy (doc) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (demo) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (supabase) (push) Failing after 18s
This commit is contained in:
600
supabase/migrations/20240730075029_init_db.sql
Normal file
600
supabase/migrations/20240730075029_init_db.sql
Normal file
@@ -0,0 +1,600 @@
|
||||
create table "public"."companies" (
|
||||
"id" bigint generated by default as identity not null,
|
||||
"created_at" timestamp with time zone not null default now(),
|
||||
"name" text not null,
|
||||
"sector" text,
|
||||
"size" smallint,
|
||||
"linkedin_url" text,
|
||||
"website" text,
|
||||
"phone_number" text,
|
||||
"address" text,
|
||||
"zipcode" text,
|
||||
"city" text,
|
||||
"stateAbbr" text,
|
||||
"sales_id" bigint,
|
||||
"context_links" json,
|
||||
"country" text,
|
||||
"description" text,
|
||||
"revenue" text,
|
||||
"tax_identifier" text,
|
||||
"logo" jsonb
|
||||
);
|
||||
|
||||
|
||||
alter table "public"."companies" enable row level security;
|
||||
|
||||
create table "public"."contactNotes" (
|
||||
"id" bigint generated by default as identity not null,
|
||||
"contact_id" bigint not null,
|
||||
"text" text,
|
||||
"date" timestamp with time zone default now(),
|
||||
"sales_id" bigint,
|
||||
"status" text,
|
||||
"attachments" jsonb[]
|
||||
);
|
||||
|
||||
|
||||
alter table "public"."contactNotes" enable row level security;
|
||||
|
||||
create table "public"."contacts" (
|
||||
"id" bigint generated by default as identity not null,
|
||||
"first_name" text,
|
||||
"last_name" text,
|
||||
"gender" text,
|
||||
"title" text,
|
||||
"email" text,
|
||||
"phone_1_number" text,
|
||||
"phone_1_type" text,
|
||||
"phone_2_number" text,
|
||||
"phone_2_type" text,
|
||||
"background" text,
|
||||
"acquisition" text,
|
||||
"avatar" jsonb,
|
||||
"first_seen" timestamp with time zone,
|
||||
"last_seen" timestamp with time zone,
|
||||
"has_newsletter" boolean,
|
||||
"status" text,
|
||||
"tags" bigint[],
|
||||
"company_id" bigint,
|
||||
"sales_id" bigint,
|
||||
"linkedin_url" text
|
||||
);
|
||||
|
||||
|
||||
alter table "public"."contacts" enable row level security;
|
||||
|
||||
create table "public"."dealNotes" (
|
||||
"id" bigint generated by default as identity not null,
|
||||
"deal_id" bigint not null,
|
||||
"type" text,
|
||||
"text" text,
|
||||
"date" timestamp with time zone default now(),
|
||||
"sales_id" bigint,
|
||||
"attachments" jsonb[]
|
||||
);
|
||||
|
||||
|
||||
alter table "public"."dealNotes" enable row level security;
|
||||
|
||||
create table "public"."deals" (
|
||||
"id" bigint generated by default as identity not null,
|
||||
"name" text not null,
|
||||
"company_id" bigint,
|
||||
"contact_ids" bigint[],
|
||||
"category" text,
|
||||
"stage" text not null,
|
||||
"description" text,
|
||||
"amount" bigint,
|
||||
"created_at" timestamp with time zone not null default now(),
|
||||
"updated_at" timestamp with time zone not null default now(),
|
||||
"archived_at" timestamp with time zone default null,
|
||||
"expected_closing_date" timestamp with time zone default null,
|
||||
"sales_id" bigint,
|
||||
"index" smallint
|
||||
);
|
||||
|
||||
|
||||
alter table "public"."deals" enable row level security;
|
||||
|
||||
create table "public"."sales" (
|
||||
"id" bigint generated by default as identity not null,
|
||||
"first_name" text not null,
|
||||
"last_name" text not null,
|
||||
"email" text not null,
|
||||
"administrator" boolean not null,
|
||||
"user_id" uuid not null,
|
||||
"avatar" jsonb,
|
||||
"disabled" boolean not null default FALSE
|
||||
);
|
||||
|
||||
|
||||
alter table "public"."sales" enable row level security;
|
||||
|
||||
create table "public"."tags" (
|
||||
"id" bigint generated by default as identity not null,
|
||||
"name" text not null,
|
||||
"color" text not null
|
||||
);
|
||||
|
||||
|
||||
alter table "public"."tags" enable row level security;
|
||||
|
||||
create table "public"."tasks" (
|
||||
"id" bigint generated by default as identity not null,
|
||||
"contact_id" bigint not null,
|
||||
"type" text,
|
||||
"text" text,
|
||||
"due_date" timestamp with time zone not null,
|
||||
"done_date" timestamp with time zone
|
||||
);
|
||||
|
||||
|
||||
alter table "public"."tasks" enable row level security;
|
||||
|
||||
CREATE UNIQUE INDEX companies_pkey ON public.companies USING btree (id);
|
||||
|
||||
CREATE UNIQUE INDEX "contactNotes_pkey" ON public."contactNotes" USING btree (id);
|
||||
|
||||
CREATE UNIQUE INDEX contacts_pkey ON public.contacts USING btree (id);
|
||||
|
||||
CREATE UNIQUE INDEX "dealNotes_pkey" ON public."dealNotes" USING btree (id);
|
||||
|
||||
CREATE UNIQUE INDEX deals_pkey ON public.deals USING btree (id);
|
||||
|
||||
CREATE UNIQUE INDEX sales_pkey ON public.sales USING btree (id);
|
||||
|
||||
CREATE UNIQUE INDEX tags_pkey ON public.tags USING btree (id);
|
||||
|
||||
CREATE UNIQUE INDEX tasks_pkey ON public.tasks USING btree (id);
|
||||
|
||||
alter table "public"."companies" add constraint "companies_pkey" PRIMARY KEY using index "companies_pkey";
|
||||
|
||||
alter table "public"."contactNotes" add constraint "contactNotes_pkey" PRIMARY KEY using index "contactNotes_pkey";
|
||||
|
||||
alter table "public"."contacts" add constraint "contacts_pkey" PRIMARY KEY using index "contacts_pkey";
|
||||
|
||||
alter table "public"."dealNotes" add constraint "dealNotes_pkey" PRIMARY KEY using index "dealNotes_pkey";
|
||||
|
||||
alter table "public"."deals" add constraint "deals_pkey" PRIMARY KEY using index "deals_pkey";
|
||||
|
||||
alter table "public"."sales" add constraint "sales_pkey" PRIMARY KEY using index "sales_pkey";
|
||||
|
||||
alter table "public"."tags" add constraint "tags_pkey" PRIMARY KEY using index "tags_pkey";
|
||||
|
||||
alter table "public"."tasks" add constraint "tasks_pkey" PRIMARY KEY using index "tasks_pkey";
|
||||
|
||||
alter table "public"."companies" add constraint "companies_sales_id_fkey" FOREIGN KEY (sales_id) REFERENCES sales(id) not valid;
|
||||
|
||||
alter table "public"."companies" validate constraint "companies_sales_id_fkey";
|
||||
|
||||
alter table "public"."contactNotes" add constraint "contactNotes_contact_id_fkey" FOREIGN KEY (contact_id) REFERENCES contacts(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
|
||||
|
||||
alter table "public"."contactNotes" validate constraint "contactNotes_contact_id_fkey";
|
||||
|
||||
alter table "public"."contactNotes" add constraint "contactNotes_sales_id_fkey" FOREIGN KEY (sales_id) REFERENCES sales(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
|
||||
|
||||
alter table "public"."contactNotes" validate constraint "contactNotes_sales_id_fkey";
|
||||
|
||||
alter table "public"."contacts" add constraint "contacts_company_id_fkey" FOREIGN KEY (company_id) REFERENCES companies(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
|
||||
|
||||
alter table "public"."contacts" validate constraint "contacts_company_id_fkey";
|
||||
|
||||
alter table "public"."contacts" add constraint "contacts_sales_id_fkey" FOREIGN KEY (sales_id) REFERENCES sales(id) not valid;
|
||||
|
||||
alter table "public"."contacts" validate constraint "contacts_sales_id_fkey";
|
||||
|
||||
alter table "public"."dealNotes" add constraint "dealNotes_deal_id_fkey" FOREIGN KEY (deal_id) REFERENCES deals(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
|
||||
|
||||
alter table "public"."dealNotes" validate constraint "dealNotes_deal_id_fkey";
|
||||
|
||||
alter table "public"."dealNotes" add constraint "dealNotes_sales_id_fkey" FOREIGN KEY (sales_id) REFERENCES sales(id) not valid;
|
||||
|
||||
alter table "public"."dealNotes" validate constraint "dealNotes_sales_id_fkey";
|
||||
|
||||
alter table "public"."deals" add constraint "deals_company_id_fkey" FOREIGN KEY (company_id) REFERENCES companies(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
|
||||
|
||||
alter table "public"."deals" validate constraint "deals_company_id_fkey";
|
||||
|
||||
alter table "public"."deals" add constraint "deals_sales_id_fkey" FOREIGN KEY (sales_id) REFERENCES sales(id) not valid;
|
||||
|
||||
alter table "public"."deals" validate constraint "deals_sales_id_fkey";
|
||||
|
||||
alter table "public"."sales" add constraint "sales_user_id_fkey" FOREIGN KEY (user_id) REFERENCES auth.users(id) not valid;
|
||||
|
||||
alter table "public"."sales" validate constraint "sales_user_id_fkey";
|
||||
|
||||
alter table "public"."tasks" add constraint "tasks_contact_id_fkey" FOREIGN KEY (contact_id) REFERENCES contacts(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
|
||||
|
||||
alter table "public"."tasks" validate constraint "tasks_contact_id_fkey";
|
||||
|
||||
set check_function_bodies = off;
|
||||
|
||||
grant delete on table "public"."companies" to "authenticated";
|
||||
grant insert on table "public"."companies" to "authenticated";
|
||||
grant select on table "public"."companies" to "authenticated";
|
||||
grant update on table "public"."companies" to "authenticated";
|
||||
|
||||
grant delete on table "public"."companies" to "service_role";
|
||||
grant insert on table "public"."companies" to "service_role";
|
||||
grant references on table "public"."companies" to "service_role";
|
||||
grant select on table "public"."companies" to "service_role";
|
||||
grant trigger on table "public"."companies" to "service_role";
|
||||
grant truncate on table "public"."companies" to "service_role";
|
||||
grant update on table "public"."companies" to "service_role";
|
||||
|
||||
grant delete on table "public"."contactNotes" to "authenticated";
|
||||
grant insert on table "public"."contactNotes" to "authenticated";
|
||||
grant select on table "public"."contactNotes" to "authenticated";
|
||||
grant update on table "public"."contactNotes" to "authenticated";
|
||||
|
||||
grant delete on table "public"."contactNotes" to "service_role";
|
||||
grant insert on table "public"."contactNotes" to "service_role";
|
||||
grant references on table "public"."contactNotes" to "service_role";
|
||||
grant select on table "public"."contactNotes" to "service_role";
|
||||
grant trigger on table "public"."contactNotes" to "service_role";
|
||||
grant truncate on table "public"."contactNotes" to "service_role";
|
||||
grant update on table "public"."contactNotes" to "service_role";
|
||||
|
||||
grant delete on table "public"."contacts" to "authenticated";
|
||||
grant insert on table "public"."contacts" to "authenticated";
|
||||
grant select on table "public"."contacts" to "authenticated";
|
||||
grant update on table "public"."contacts" to "authenticated";
|
||||
|
||||
grant delete on table "public"."contacts" to "service_role";
|
||||
grant insert on table "public"."contacts" to "service_role";
|
||||
grant references on table "public"."contacts" to "service_role";
|
||||
grant select on table "public"."contacts" to "service_role";
|
||||
grant trigger on table "public"."contacts" to "service_role";
|
||||
grant truncate on table "public"."contacts" to "service_role";
|
||||
grant update on table "public"."contacts" to "service_role";
|
||||
|
||||
grant delete on table "public"."dealNotes" to "authenticated";
|
||||
grant insert on table "public"."dealNotes" to "authenticated";
|
||||
grant select on table "public"."dealNotes" to "authenticated";
|
||||
grant update on table "public"."dealNotes" to "authenticated";
|
||||
|
||||
grant delete on table "public"."dealNotes" to "service_role";
|
||||
grant insert on table "public"."dealNotes" to "service_role";
|
||||
grant references on table "public"."dealNotes" to "service_role";
|
||||
grant select on table "public"."dealNotes" to "service_role";
|
||||
grant trigger on table "public"."dealNotes" to "service_role";
|
||||
grant truncate on table "public"."dealNotes" to "service_role";
|
||||
grant update on table "public"."dealNotes" to "service_role";
|
||||
|
||||
|
||||
grant delete on table "public"."deals" to "authenticated";
|
||||
grant insert on table "public"."deals" to "authenticated";
|
||||
grant select on table "public"."deals" to "authenticated";
|
||||
grant update on table "public"."deals" to "authenticated";
|
||||
|
||||
grant delete on table "public"."deals" to "service_role";
|
||||
grant insert on table "public"."deals" to "service_role";
|
||||
grant references on table "public"."deals" to "service_role";
|
||||
grant select on table "public"."deals" to "service_role";
|
||||
grant trigger on table "public"."deals" to "service_role";
|
||||
grant truncate on table "public"."deals" to "service_role";
|
||||
grant update on table "public"."deals" to "service_role";
|
||||
|
||||
grant delete on table "public"."sales" to "authenticated";
|
||||
grant insert on table "public"."sales" to "authenticated";
|
||||
grant select on table "public"."sales" to "authenticated";
|
||||
grant update on table "public"."sales" to "authenticated";
|
||||
|
||||
grant delete on table "public"."sales" to "service_role";
|
||||
grant insert on table "public"."sales" to "service_role";
|
||||
grant references on table "public"."sales" to "service_role";
|
||||
grant select on table "public"."sales" to "service_role";
|
||||
grant trigger on table "public"."sales" to "service_role";
|
||||
grant truncate on table "public"."sales" to "service_role";
|
||||
grant update on table "public"."sales" to "service_role";
|
||||
|
||||
grant delete on table "public"."tags" to "authenticated";
|
||||
grant insert on table "public"."tags" to "authenticated";
|
||||
grant select on table "public"."tags" to "authenticated";
|
||||
grant update on table "public"."tags" to "authenticated";
|
||||
|
||||
grant delete on table "public"."tags" to "service_role";
|
||||
grant insert on table "public"."tags" to "service_role";
|
||||
grant references on table "public"."tags" to "service_role";
|
||||
grant select on table "public"."tags" to "service_role";
|
||||
grant trigger on table "public"."tags" to "service_role";
|
||||
grant truncate on table "public"."tags" to "service_role";
|
||||
grant update on table "public"."tags" to "service_role";
|
||||
|
||||
grant delete on table "public"."tasks" to "authenticated";
|
||||
grant insert on table "public"."tasks" to "authenticated";
|
||||
grant select on table "public"."tasks" to "authenticated";
|
||||
grant update on table "public"."tasks" to "authenticated";
|
||||
|
||||
grant delete on table "public"."tasks" to "service_role";
|
||||
grant insert on table "public"."tasks" to "service_role";
|
||||
grant references on table "public"."tasks" to "service_role";
|
||||
grant select on table "public"."tasks" to "service_role";
|
||||
grant trigger on table "public"."tasks" to "service_role";
|
||||
grant truncate on table "public"."tasks" to "service_role";
|
||||
grant update on table "public"."tasks" to "service_role";
|
||||
|
||||
create policy "Enable insert for authenticated users only"
|
||||
on "public"."companies"
|
||||
as permissive
|
||||
for insert
|
||||
to authenticated
|
||||
with check (true);
|
||||
|
||||
|
||||
create policy "Enable read access for authenticated users"
|
||||
on "public"."companies"
|
||||
as permissive
|
||||
for select
|
||||
to authenticated
|
||||
using (true);
|
||||
|
||||
|
||||
create policy "Enable update for authenticated users only"
|
||||
on "public"."companies"
|
||||
as permissive
|
||||
for update
|
||||
to authenticated
|
||||
using (true)
|
||||
with check (true);
|
||||
|
||||
|
||||
create policy "Enable insert for authenticated users only"
|
||||
on "public"."contactNotes"
|
||||
as permissive
|
||||
for insert
|
||||
to authenticated
|
||||
with check (true);
|
||||
|
||||
|
||||
create policy "Enable read access for authenticated users"
|
||||
on "public"."contactNotes"
|
||||
as permissive
|
||||
for select
|
||||
to authenticated
|
||||
using (true);
|
||||
|
||||
|
||||
create policy "Enable insert for authenticated users only"
|
||||
on "public"."contacts"
|
||||
as permissive
|
||||
for insert
|
||||
to authenticated
|
||||
with check (true);
|
||||
|
||||
|
||||
create policy "Enable read access for authenticated users"
|
||||
on "public"."contacts"
|
||||
as permissive
|
||||
for select
|
||||
to authenticated
|
||||
using (true);
|
||||
|
||||
|
||||
create policy "Enable update for authenticated users only"
|
||||
on "public"."contacts"
|
||||
as permissive
|
||||
for update
|
||||
to authenticated
|
||||
using (true)
|
||||
with check (true);
|
||||
|
||||
|
||||
create policy "Enable insert for authenticated users only"
|
||||
on "public"."dealNotes"
|
||||
as permissive
|
||||
for insert
|
||||
to authenticated
|
||||
with check (true);
|
||||
|
||||
|
||||
create policy "Enable read access for authenticated users"
|
||||
on "public"."dealNotes"
|
||||
as permissive
|
||||
for select
|
||||
to authenticated
|
||||
using (true);
|
||||
|
||||
|
||||
create policy "Enable insert for authenticated users only"
|
||||
on "public"."deals"
|
||||
as permissive
|
||||
for insert
|
||||
to authenticated
|
||||
with check (true);
|
||||
|
||||
|
||||
create policy "Enable read access for authenticated users"
|
||||
on "public"."deals"
|
||||
as permissive
|
||||
for select
|
||||
to authenticated
|
||||
using (true);
|
||||
|
||||
|
||||
create policy "Enable update for authenticated users only"
|
||||
on "public"."deals"
|
||||
as permissive
|
||||
for update
|
||||
to authenticated
|
||||
using (true)
|
||||
with check (true);
|
||||
|
||||
|
||||
create policy "Enable insert for authenticated users only"
|
||||
on "public"."sales"
|
||||
as permissive
|
||||
for insert
|
||||
to authenticated
|
||||
with check (true);
|
||||
|
||||
|
||||
create policy "Enable update for authenticated users only"
|
||||
on "public"."sales"
|
||||
as permissive
|
||||
for update
|
||||
to authenticated
|
||||
using (true)
|
||||
with check (true);
|
||||
|
||||
|
||||
create policy "Enable read access for authenticated users"
|
||||
on "public"."sales"
|
||||
as permissive
|
||||
for select
|
||||
to authenticated
|
||||
using (true);
|
||||
|
||||
|
||||
create policy "Enable insert for authenticated users only"
|
||||
on "public"."tags"
|
||||
as permissive
|
||||
for insert
|
||||
to authenticated
|
||||
with check (true);
|
||||
|
||||
|
||||
create policy "Enable read access for authenticated users"
|
||||
on "public"."tags"
|
||||
as permissive
|
||||
for select
|
||||
to authenticated
|
||||
using (true);
|
||||
|
||||
|
||||
create policy "Enable insert for authenticated users only"
|
||||
on "public"."tasks"
|
||||
as permissive
|
||||
for insert
|
||||
to authenticated
|
||||
with check (true);
|
||||
|
||||
|
||||
create policy "Enable read access for authenticated users"
|
||||
on "public"."tasks"
|
||||
as permissive
|
||||
for select
|
||||
to authenticated
|
||||
using (true);
|
||||
|
||||
|
||||
create policy "Company Delete Policy"
|
||||
on "public"."companies"
|
||||
as permissive
|
||||
for delete
|
||||
to authenticated
|
||||
using (true);
|
||||
|
||||
|
||||
create policy "Contact Notes Delete Policy"
|
||||
on "public"."contactNotes"
|
||||
as permissive
|
||||
for delete
|
||||
to authenticated
|
||||
using (true);
|
||||
|
||||
|
||||
create policy "Contact Notes Update policy"
|
||||
on "public"."contactNotes"
|
||||
as permissive
|
||||
for update
|
||||
to authenticated
|
||||
using (true);
|
||||
|
||||
|
||||
create policy "Contact Delete Policy"
|
||||
on "public"."contacts"
|
||||
as permissive
|
||||
for delete
|
||||
to authenticated
|
||||
using (true);
|
||||
|
||||
|
||||
create policy "Deal Notes Delete Policy"
|
||||
on "public"."dealNotes"
|
||||
as permissive
|
||||
for delete
|
||||
to authenticated
|
||||
using (true);
|
||||
|
||||
|
||||
create policy "Deal Notes Update Policy"
|
||||
on "public"."dealNotes"
|
||||
as permissive
|
||||
for update
|
||||
to authenticated
|
||||
using (true);
|
||||
|
||||
|
||||
create policy "Deals Delete Policy"
|
||||
on "public"."deals"
|
||||
as permissive
|
||||
for delete
|
||||
to authenticated
|
||||
using (true);
|
||||
|
||||
|
||||
create policy "Task Delete Policy"
|
||||
on "public"."tasks"
|
||||
as permissive
|
||||
for delete
|
||||
to authenticated
|
||||
using (true);
|
||||
|
||||
|
||||
create policy "Task Update Policy"
|
||||
on "public"."tasks"
|
||||
as permissive
|
||||
for update
|
||||
to authenticated
|
||||
using (true);
|
||||
|
||||
|
||||
-- Use Postgres to create a bucket.
|
||||
|
||||
insert into storage.buckets
|
||||
(id, name, public)
|
||||
values
|
||||
('attachments', 'attachments', true);
|
||||
|
||||
CREATE POLICY "Attachments 1mt4rzk_0" ON storage.objects FOR SELECT TO authenticated USING (bucket_id = 'attachments');
|
||||
CREATE POLICY "Attachments 1mt4rzk_1" ON storage.objects FOR INSERT TO authenticated WITH CHECK (bucket_id = 'attachments');
|
||||
CREATE POLICY "Attachments 1mt4rzk_3" ON storage.objects FOR DELETE TO authenticated USING (bucket_id = 'attachments');
|
||||
|
||||
-- Use Postgres to create views for companies.
|
||||
|
||||
create view "public"."companies_summary"
|
||||
with (security_invoker=on)
|
||||
as
|
||||
select
|
||||
c.*,
|
||||
count(distinct d.id) as nb_deals,
|
||||
count(distinct co.id) as nb_contacts
|
||||
from
|
||||
"public"."companies" c
|
||||
left join
|
||||
"public"."deals" d on c.id = d.company_id
|
||||
left join
|
||||
"public"."contacts" co on c.id = co.company_id
|
||||
group by
|
||||
c.id;
|
||||
|
||||
-- Use Postgres to create views for contacts.
|
||||
|
||||
create view "public"."contacts_summary"
|
||||
with (security_invoker=on)
|
||||
as
|
||||
select
|
||||
co.*,
|
||||
c.name as company_name,
|
||||
count(distinct t.id) as nb_tasks
|
||||
from
|
||||
"public"."contacts" co
|
||||
left join
|
||||
"public"."tasks" t on co.id = t.contact_id
|
||||
left join
|
||||
"public"."companies" c on co.company_id = c.id
|
||||
group by
|
||||
co.id, c.name;
|
||||
|
||||
|
||||
57
supabase/migrations/20240730075425_init_triggers.sql
Normal file
57
supabase/migrations/20240730075425_init_triggers.sql
Normal file
@@ -0,0 +1,57 @@
|
||||
create function public.handle_new_user()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
sales_count int;
|
||||
begin
|
||||
select count(id) into sales_count
|
||||
from public.sales;
|
||||
|
||||
insert into public.sales (first_name, last_name, email, user_id, administrator)
|
||||
values (
|
||||
new.raw_user_meta_data ->> 'first_name',
|
||||
new.raw_user_meta_data ->> 'last_name',
|
||||
new.email,
|
||||
new.id,
|
||||
case when sales_count > 0 then FALSE else TRUE end
|
||||
);
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create function public.handle_update_user()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer set search_path = ''
|
||||
as $$
|
||||
begin
|
||||
update public.sales
|
||||
set
|
||||
first_name = new.raw_user_meta_data ->> 'first_name',
|
||||
last_name = new.raw_user_meta_data ->> 'last_name',
|
||||
email = new.email
|
||||
where user_id = new.id;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
|
||||
create unique index "uq__sales__user_id" on public.sales (user_id);
|
||||
|
||||
create trigger on_auth_user_created
|
||||
after insert on auth.users
|
||||
for each row execute procedure public.handle_new_user();
|
||||
|
||||
create trigger on_auth_user_updated
|
||||
after update on auth.users
|
||||
for each row execute procedure public.handle_update_user();
|
||||
|
||||
create view init_state
|
||||
with (security_invoker=off)
|
||||
as
|
||||
select count(id) as is_initialized
|
||||
from public.sales
|
||||
limit 1;
|
||||
1
supabase/migrations/20240806124555_task_sales_id.sql
Normal file
1
supabase/migrations/20240806124555_task_sales_id.sql
Normal file
@@ -0,0 +1 @@
|
||||
alter table "public"."tasks" add column "sales_id" bigint;
|
||||
20
supabase/migrations/20240807082449_remove-aquisition.sql
Normal file
20
supabase/migrations/20240807082449_remove-aquisition.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- this will drop the `contacts_summary` view as well
|
||||
ALTER TABLE "public"."contacts"
|
||||
DROP COLUMN acquisition CASCADE;
|
||||
|
||||
-- We need to recreate the view with the new schema
|
||||
|
||||
create view "public"."contacts_summary"
|
||||
as
|
||||
select
|
||||
co.*,
|
||||
c.name as company_name,
|
||||
count(distinct t.id) as nb_tasks
|
||||
from
|
||||
"public"."contacts" co
|
||||
left join
|
||||
"public"."tasks" t on co.id = t.contact_id
|
||||
left join
|
||||
"public"."companies" c on co.company_id = c.id
|
||||
group by
|
||||
co.id, c.name;
|
||||
@@ -0,0 +1,9 @@
|
||||
create or replace view init_state
|
||||
with (security_invoker=off)
|
||||
as
|
||||
select count(id) as is_initialized
|
||||
from (
|
||||
select id
|
||||
from public.sales
|
||||
limit 1
|
||||
) as sub;
|
||||
18
supabase/migrations/20240813084010_tags_policy.sql
Normal file
18
supabase/migrations/20240813084010_tags_policy.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
create policy "Enable delete for authenticated users only"
|
||||
on "public"."tags"
|
||||
as permissive
|
||||
for delete
|
||||
to authenticated
|
||||
using (true);
|
||||
|
||||
|
||||
create policy "Enable update for authenticated users only"
|
||||
on "public"."tags"
|
||||
as permissive
|
||||
for update
|
||||
to authenticated
|
||||
using (true);
|
||||
|
||||
|
||||
|
||||
|
||||
7
supabase/migrations/20241104153231_sales_policies.sql
Normal file
7
supabase/migrations/20241104153231_sales_policies.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
create schema if not exists "private";
|
||||
|
||||
set check_function_bodies = off;
|
||||
|
||||
drop policy "Enable insert for authenticated users only" on "public"."sales";
|
||||
|
||||
drop policy "Enable update for authenticated users only" on "public"."sales";
|
||||
43
supabase/migrations/20250109152531_email_jsonb.sql
Normal file
43
supabase/migrations/20250109152531_email_jsonb.sql
Normal file
@@ -0,0 +1,43 @@
|
||||
alter table contacts add column email_jsonb jsonb;
|
||||
|
||||
update contacts set email_jsonb = ('[{"email": "' || email || '", "type": "Other"}]')::jsonb;
|
||||
|
||||
drop view contacts_summary;
|
||||
|
||||
alter table contacts drop column email;
|
||||
|
||||
create view contacts_summary
|
||||
as
|
||||
select
|
||||
co.id,
|
||||
co.first_name,
|
||||
co.last_name,
|
||||
co.gender,
|
||||
co.title,
|
||||
co.email_jsonb,
|
||||
jsonb_path_query_array(co.email_jsonb, '$[*].email')::text as email_fts,
|
||||
co.phone_1_number,
|
||||
co.phone_1_type,
|
||||
co.phone_2_number,
|
||||
co.phone_2_type,
|
||||
co.background,
|
||||
co.avatar,
|
||||
co.first_seen,
|
||||
co.last_seen,
|
||||
co.has_newsletter,
|
||||
co.status,
|
||||
co.tags,
|
||||
co.company_id,
|
||||
co.sales_id,
|
||||
co.linkedin_url,
|
||||
c.name as company_name,
|
||||
count(distinct t.id) as nb_tasks
|
||||
from
|
||||
contacts co
|
||||
left join
|
||||
tasks t on co.id = t.contact_id
|
||||
left join
|
||||
companies c on co.company_id = c.id
|
||||
group by
|
||||
co.id, c.name;
|
||||
|
||||
67
supabase/migrations/20250113132531_phone_jsonb.sql
Normal file
67
supabase/migrations/20250113132531_phone_jsonb.sql
Normal file
@@ -0,0 +1,67 @@
|
||||
alter table contacts add column phone_jsonb jsonb;
|
||||
|
||||
update contacts set phone_jsonb =
|
||||
concat(
|
||||
'[',
|
||||
case when phone_1_number is not null then
|
||||
concat(
|
||||
'{"number":"',
|
||||
phone_1_number,
|
||||
'","type":"',
|
||||
phone_1_type,
|
||||
'"}'
|
||||
)
|
||||
else null end,
|
||||
case when phone_2_number is not null then
|
||||
concat(
|
||||
',',
|
||||
'{"number":"',
|
||||
phone_2_number,
|
||||
'","type":"',
|
||||
phone_2_type,
|
||||
'"}'
|
||||
)
|
||||
else null end,
|
||||
']'
|
||||
)::jsonb;
|
||||
|
||||
drop view contacts_summary;
|
||||
|
||||
alter table contacts drop column phone_1_number;
|
||||
alter table contacts drop column phone_1_type;
|
||||
alter table contacts drop column phone_2_number;
|
||||
alter table contacts drop column phone_2_type;
|
||||
|
||||
create view contacts_summary
|
||||
as
|
||||
select
|
||||
co.id,
|
||||
co.first_name,
|
||||
co.last_name,
|
||||
co.gender,
|
||||
co.title,
|
||||
co.email_jsonb,
|
||||
jsonb_path_query_array(co.email_jsonb, '$[*].email')::text as email_fts,
|
||||
co.phone_jsonb,
|
||||
jsonb_path_query_array(co.phone_jsonb, '$[*].number')::text as phone_fts,
|
||||
co.background,
|
||||
co.avatar,
|
||||
co.first_seen,
|
||||
co.last_seen,
|
||||
co.has_newsletter,
|
||||
co.status,
|
||||
co.tags,
|
||||
co.company_id,
|
||||
co.sales_id,
|
||||
co.linkedin_url,
|
||||
c.name as company_name,
|
||||
count(distinct t.id) as nb_tasks
|
||||
from
|
||||
contacts co
|
||||
left join
|
||||
tasks t on co.id = t.contact_id
|
||||
left join
|
||||
companies c on co.company_id = c.id
|
||||
group by
|
||||
co.id, c.name;
|
||||
|
||||
153
supabase/migrations/20251204172855_merge_contacts_function.sql
Normal file
153
supabase/migrations/20251204172855_merge_contacts_function.sql
Normal file
@@ -0,0 +1,153 @@
|
||||
-- Create function to merge contacts server-side with transaction support
|
||||
CREATE OR REPLACE FUNCTION merge_contacts(loser_id bigint, winner_id bigint)
|
||||
RETURNS bigint
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
AS $$
|
||||
DECLARE
|
||||
winner_contact contacts%ROWTYPE;
|
||||
loser_contact contacts%ROWTYPE;
|
||||
deal_record RECORD;
|
||||
merged_emails jsonb;
|
||||
merged_phones jsonb;
|
||||
merged_tags bigint[];
|
||||
winner_emails jsonb;
|
||||
loser_emails jsonb;
|
||||
winner_phones jsonb;
|
||||
loser_phones jsonb;
|
||||
email_map jsonb;
|
||||
phone_map jsonb;
|
||||
BEGIN
|
||||
-- Fetch both contacts
|
||||
SELECT * INTO winner_contact FROM contacts WHERE id = winner_id;
|
||||
SELECT * INTO loser_contact FROM contacts WHERE id = loser_id;
|
||||
|
||||
IF winner_contact IS NULL OR loser_contact IS NULL THEN
|
||||
RAISE EXCEPTION 'Contact not found';
|
||||
END IF;
|
||||
|
||||
-- 1. Reassign tasks from loser to winner
|
||||
UPDATE tasks SET contact_id = winner_id WHERE contact_id = loser_id;
|
||||
|
||||
-- 2. Reassign contact notes from loser to winner
|
||||
UPDATE "contactNotes" SET contact_id = winner_id WHERE contact_id = loser_id;
|
||||
|
||||
-- 3. Update deals - replace loser with winner in contact_ids array
|
||||
FOR deal_record IN
|
||||
SELECT id, contact_ids
|
||||
FROM deals
|
||||
WHERE contact_ids @> ARRAY[loser_id]
|
||||
LOOP
|
||||
UPDATE deals
|
||||
SET contact_ids = (
|
||||
SELECT ARRAY(
|
||||
SELECT DISTINCT unnest(
|
||||
array_remove(deal_record.contact_ids, loser_id) || ARRAY[winner_id]
|
||||
)
|
||||
)
|
||||
)
|
||||
WHERE id = deal_record.id;
|
||||
END LOOP;
|
||||
|
||||
-- 4. Merge contact data
|
||||
|
||||
-- Get email arrays
|
||||
winner_emails := COALESCE(winner_contact.email_jsonb, '[]'::jsonb);
|
||||
loser_emails := COALESCE(loser_contact.email_jsonb, '[]'::jsonb);
|
||||
|
||||
-- Merge emails with deduplication by email address
|
||||
-- Build a map of email -> email object, then convert back to array
|
||||
email_map := '{}'::jsonb;
|
||||
|
||||
-- Add winner emails to map
|
||||
IF jsonb_array_length(winner_emails) > 0 THEN
|
||||
FOR i IN 0..jsonb_array_length(winner_emails)-1 LOOP
|
||||
email_map := email_map || jsonb_build_object(
|
||||
winner_emails->i->>'email',
|
||||
winner_emails->i
|
||||
);
|
||||
END LOOP;
|
||||
END IF;
|
||||
|
||||
-- Add loser emails to map (won't overwrite existing keys)
|
||||
IF jsonb_array_length(loser_emails) > 0 THEN
|
||||
FOR i IN 0..jsonb_array_length(loser_emails)-1 LOOP
|
||||
IF NOT email_map ? (loser_emails->i->>'email') THEN
|
||||
email_map := email_map || jsonb_build_object(
|
||||
loser_emails->i->>'email',
|
||||
loser_emails->i
|
||||
);
|
||||
END IF;
|
||||
END LOOP;
|
||||
END IF;
|
||||
|
||||
-- Convert map back to array
|
||||
merged_emails := (SELECT jsonb_agg(value) FROM jsonb_each(email_map));
|
||||
merged_emails := COALESCE(merged_emails, '[]'::jsonb);
|
||||
|
||||
-- Get phone arrays
|
||||
winner_phones := COALESCE(winner_contact.phone_jsonb, '[]'::jsonb);
|
||||
loser_phones := COALESCE(loser_contact.phone_jsonb, '[]'::jsonb);
|
||||
|
||||
-- Merge phones with deduplication by number
|
||||
phone_map := '{}'::jsonb;
|
||||
|
||||
-- Add winner phones to map
|
||||
IF jsonb_array_length(winner_phones) > 0 THEN
|
||||
FOR i IN 0..jsonb_array_length(winner_phones)-1 LOOP
|
||||
phone_map := phone_map || jsonb_build_object(
|
||||
winner_phones->i->>'number',
|
||||
winner_phones->i
|
||||
);
|
||||
END LOOP;
|
||||
END IF;
|
||||
|
||||
-- Add loser phones to map (won't overwrite existing keys)
|
||||
IF jsonb_array_length(loser_phones) > 0 THEN
|
||||
FOR i IN 0..jsonb_array_length(loser_phones)-1 LOOP
|
||||
IF NOT phone_map ? (loser_phones->i->>'number') THEN
|
||||
phone_map := phone_map || jsonb_build_object(
|
||||
loser_phones->i->>'number',
|
||||
loser_phones->i
|
||||
);
|
||||
END IF;
|
||||
END LOOP;
|
||||
END IF;
|
||||
|
||||
-- Convert map back to array
|
||||
merged_phones := (SELECT jsonb_agg(value) FROM jsonb_each(phone_map));
|
||||
merged_phones := COALESCE(merged_phones, '[]'::jsonb);
|
||||
|
||||
-- Merge tags (remove duplicates)
|
||||
merged_tags := ARRAY(
|
||||
SELECT DISTINCT unnest(
|
||||
COALESCE(winner_contact.tags, ARRAY[]::bigint[]) ||
|
||||
COALESCE(loser_contact.tags, ARRAY[]::bigint[])
|
||||
)
|
||||
);
|
||||
|
||||
-- 5. Update winner with merged data
|
||||
UPDATE contacts SET
|
||||
avatar = COALESCE(winner_contact.avatar, loser_contact.avatar),
|
||||
gender = COALESCE(winner_contact.gender, loser_contact.gender),
|
||||
first_name = COALESCE(winner_contact.first_name, loser_contact.first_name),
|
||||
last_name = COALESCE(winner_contact.last_name, loser_contact.last_name),
|
||||
title = COALESCE(winner_contact.title, loser_contact.title),
|
||||
company_id = COALESCE(winner_contact.company_id, loser_contact.company_id),
|
||||
email_jsonb = merged_emails,
|
||||
phone_jsonb = merged_phones,
|
||||
linkedin_url = COALESCE(winner_contact.linkedin_url, loser_contact.linkedin_url),
|
||||
background = COALESCE(winner_contact.background, loser_contact.background),
|
||||
has_newsletter = COALESCE(winner_contact.has_newsletter, loser_contact.has_newsletter),
|
||||
first_seen = LEAST(COALESCE(winner_contact.first_seen, loser_contact.first_seen), COALESCE(loser_contact.first_seen, winner_contact.first_seen)),
|
||||
last_seen = GREATEST(COALESCE(winner_contact.last_seen, loser_contact.last_seen), COALESCE(loser_contact.last_seen, winner_contact.last_seen)),
|
||||
sales_id = COALESCE(winner_contact.sales_id, loser_contact.sales_id),
|
||||
tags = merged_tags
|
||||
WHERE id = winner_id;
|
||||
|
||||
-- 6. Delete loser contact
|
||||
DELETE FROM contacts WHERE id = loser_id;
|
||||
|
||||
RETURN winner_id;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Drop the merge_contacts function (reverting to edge function approach)
|
||||
DROP FUNCTION IF EXISTS merge_contacts(bigint, bigint);
|
||||
39
supabase/migrations/20260108160722_task_default_sales.sql
Normal file
39
supabase/migrations/20260108160722_task_default_sales.sql
Normal file
@@ -0,0 +1,39 @@
|
||||
CREATE OR REPLACE FUNCTION set_sales_id_default()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.sales_id IS NULL THEN
|
||||
SELECT id INTO NEW.sales_id FROM sales WHERE user_id = auth.uid();
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER set_task_sales_id_trigger
|
||||
BEFORE INSERT ON tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION set_sales_id_default();
|
||||
|
||||
CREATE TRIGGER set_contact_sales_id_trigger
|
||||
BEFORE INSERT ON contacts
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION set_sales_id_default();
|
||||
|
||||
CREATE TRIGGER set_contact_notes_sales_id_trigger
|
||||
BEFORE INSERT ON "contactNotes"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION set_sales_id_default();
|
||||
|
||||
CREATE TRIGGER set_company_sales_id_trigger
|
||||
BEFORE INSERT ON companies
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION set_sales_id_default();
|
||||
|
||||
CREATE TRIGGER set_deal_sales_id_trigger
|
||||
BEFORE INSERT ON deals
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION set_sales_id_default();
|
||||
|
||||
CREATE TRIGGER set_deal_notes_sales_id_trigger
|
||||
BEFORE INSERT ON "dealNotes"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION set_sales_id_default();
|
||||
158
supabase/migrations/20260115150819_snake_case_renaming.sql
Normal file
158
supabase/migrations/20260115150819_snake_case_renaming.sql
Normal file
@@ -0,0 +1,158 @@
|
||||
alter table "public"."companies" rename column "stateAbbr" TO "state_abbr";
|
||||
alter view "public"."companies_summary" rename column "stateAbbr" TO "state_abbr";
|
||||
alter table "public"."contactNotes" rename to "contact_notes";
|
||||
alter table "public"."dealNotes" rename to "deal_notes";
|
||||
|
||||
-- Create function to merge contacts server-side with transaction support
|
||||
CREATE OR REPLACE FUNCTION merge_contacts(loser_id bigint, winner_id bigint)
|
||||
RETURNS bigint
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
AS $$
|
||||
DECLARE
|
||||
winner_contact contacts%ROWTYPE;
|
||||
loser_contact contacts%ROWTYPE;
|
||||
deal_record RECORD;
|
||||
merged_emails jsonb;
|
||||
merged_phones jsonb;
|
||||
merged_tags bigint[];
|
||||
winner_emails jsonb;
|
||||
loser_emails jsonb;
|
||||
winner_phones jsonb;
|
||||
loser_phones jsonb;
|
||||
email_map jsonb;
|
||||
phone_map jsonb;
|
||||
BEGIN
|
||||
-- Fetch both contacts
|
||||
SELECT * INTO winner_contact FROM contacts WHERE id = winner_id;
|
||||
SELECT * INTO loser_contact FROM contacts WHERE id = loser_id;
|
||||
|
||||
IF winner_contact IS NULL OR loser_contact IS NULL THEN
|
||||
RAISE EXCEPTION 'Contact not found';
|
||||
END IF;
|
||||
|
||||
-- 1. Reassign tasks from loser to winner
|
||||
UPDATE tasks SET contact_id = winner_id WHERE contact_id = loser_id;
|
||||
|
||||
-- 2. Reassign contact notes from loser to winner
|
||||
UPDATE "contact_notes" SET contact_id = winner_id WHERE contact_id = loser_id;
|
||||
|
||||
-- 3. Update deals - replace loser with winner in contact_ids array
|
||||
FOR deal_record IN
|
||||
SELECT id, contact_ids
|
||||
FROM deals
|
||||
WHERE contact_ids @> ARRAY[loser_id]
|
||||
LOOP
|
||||
UPDATE deals
|
||||
SET contact_ids = (
|
||||
SELECT ARRAY(
|
||||
SELECT DISTINCT unnest(
|
||||
array_remove(deal_record.contact_ids, loser_id) || ARRAY[winner_id]
|
||||
)
|
||||
)
|
||||
)
|
||||
WHERE id = deal_record.id;
|
||||
END LOOP;
|
||||
|
||||
-- 4. Merge contact data
|
||||
|
||||
-- Get email arrays
|
||||
winner_emails := COALESCE(winner_contact.email_jsonb, '[]'::jsonb);
|
||||
loser_emails := COALESCE(loser_contact.email_jsonb, '[]'::jsonb);
|
||||
|
||||
-- Merge emails with deduplication by email address
|
||||
-- Build a map of email -> email object, then convert back to array
|
||||
email_map := '{}'::jsonb;
|
||||
|
||||
-- Add winner emails to map
|
||||
IF jsonb_array_length(winner_emails) > 0 THEN
|
||||
FOR i IN 0..jsonb_array_length(winner_emails)-1 LOOP
|
||||
email_map := email_map || jsonb_build_object(
|
||||
winner_emails->i->>'email',
|
||||
winner_emails->i
|
||||
);
|
||||
END LOOP;
|
||||
END IF;
|
||||
|
||||
-- Add loser emails to map (won't overwrite existing keys)
|
||||
IF jsonb_array_length(loser_emails) > 0 THEN
|
||||
FOR i IN 0..jsonb_array_length(loser_emails)-1 LOOP
|
||||
IF NOT email_map ? (loser_emails->i->>'email') THEN
|
||||
email_map := email_map || jsonb_build_object(
|
||||
loser_emails->i->>'email',
|
||||
loser_emails->i
|
||||
);
|
||||
END IF;
|
||||
END LOOP;
|
||||
END IF;
|
||||
|
||||
-- Convert map back to array
|
||||
merged_emails := (SELECT jsonb_agg(value) FROM jsonb_each(email_map));
|
||||
merged_emails := COALESCE(merged_emails, '[]'::jsonb);
|
||||
|
||||
-- Get phone arrays
|
||||
winner_phones := COALESCE(winner_contact.phone_jsonb, '[]'::jsonb);
|
||||
loser_phones := COALESCE(loser_contact.phone_jsonb, '[]'::jsonb);
|
||||
|
||||
-- Merge phones with deduplication by number
|
||||
phone_map := '{}'::jsonb;
|
||||
|
||||
-- Add winner phones to map
|
||||
IF jsonb_array_length(winner_phones) > 0 THEN
|
||||
FOR i IN 0..jsonb_array_length(winner_phones)-1 LOOP
|
||||
phone_map := phone_map || jsonb_build_object(
|
||||
winner_phones->i->>'number',
|
||||
winner_phones->i
|
||||
);
|
||||
END LOOP;
|
||||
END IF;
|
||||
|
||||
-- Add loser phones to map (won't overwrite existing keys)
|
||||
IF jsonb_array_length(loser_phones) > 0 THEN
|
||||
FOR i IN 0..jsonb_array_length(loser_phones)-1 LOOP
|
||||
IF NOT phone_map ? (loser_phones->i->>'number') THEN
|
||||
phone_map := phone_map || jsonb_build_object(
|
||||
loser_phones->i->>'number',
|
||||
loser_phones->i
|
||||
);
|
||||
END IF;
|
||||
END LOOP;
|
||||
END IF;
|
||||
|
||||
-- Convert map back to array
|
||||
merged_phones := (SELECT jsonb_agg(value) FROM jsonb_each(phone_map));
|
||||
merged_phones := COALESCE(merged_phones, '[]'::jsonb);
|
||||
|
||||
-- Merge tags (remove duplicates)
|
||||
merged_tags := ARRAY(
|
||||
SELECT DISTINCT unnest(
|
||||
COALESCE(winner_contact.tags, ARRAY[]::bigint[]) ||
|
||||
COALESCE(loser_contact.tags, ARRAY[]::bigint[])
|
||||
)
|
||||
);
|
||||
|
||||
-- 5. Update winner with merged data
|
||||
UPDATE contacts SET
|
||||
avatar = COALESCE(winner_contact.avatar, loser_contact.avatar),
|
||||
gender = COALESCE(winner_contact.gender, loser_contact.gender),
|
||||
first_name = COALESCE(winner_contact.first_name, loser_contact.first_name),
|
||||
last_name = COALESCE(winner_contact.last_name, loser_contact.last_name),
|
||||
title = COALESCE(winner_contact.title, loser_contact.title),
|
||||
company_id = COALESCE(winner_contact.company_id, loser_contact.company_id),
|
||||
email_jsonb = merged_emails,
|
||||
phone_jsonb = merged_phones,
|
||||
linkedin_url = COALESCE(winner_contact.linkedin_url, loser_contact.linkedin_url),
|
||||
background = COALESCE(winner_contact.background, loser_contact.background),
|
||||
has_newsletter = COALESCE(winner_contact.has_newsletter, loser_contact.has_newsletter),
|
||||
first_seen = LEAST(COALESCE(winner_contact.first_seen, loser_contact.first_seen), COALESCE(loser_contact.first_seen, winner_contact.first_seen)),
|
||||
last_seen = GREATEST(COALESCE(winner_contact.last_seen, loser_contact.last_seen), COALESCE(loser_contact.last_seen, winner_contact.last_seen)),
|
||||
sales_id = COALESCE(winner_contact.sales_id, loser_contact.sales_id),
|
||||
tags = merged_tags
|
||||
WHERE id = winner_id;
|
||||
|
||||
-- 6. Delete loser contact
|
||||
DELETE FROM contacts WHERE id = loser_id;
|
||||
|
||||
RETURN winner_id;
|
||||
END;
|
||||
$$;
|
||||
200
supabase/migrations/20260127140209_imports.sql
Normal file
200
supabase/migrations/20260127140209_imports.sql
Normal file
@@ -0,0 +1,200 @@
|
||||
create extension if not exists "http" with schema "extensions";
|
||||
|
||||
create table "public"."favicons_excluded_domains" (
|
||||
"id" bigint generated by default as identity not null,
|
||||
"domain" text not null
|
||||
);
|
||||
|
||||
|
||||
alter table "public"."favicons_excluded_domains" enable row level security;
|
||||
|
||||
CREATE UNIQUE INDEX favicons_excluded_domains_pkey ON public.favicons_excluded_domains USING btree (id);
|
||||
|
||||
alter table "public"."favicons_excluded_domains" add constraint "favicons_excluded_domains_pkey" PRIMARY KEY using index "favicons_excluded_domains_pkey";
|
||||
|
||||
alter table "public"."tasks" alter column "due_date" drop not null;
|
||||
|
||||
set check_function_bodies = off;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_avatar_for_email(email text)
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
AS $function$declare email_hash text;
|
||||
declare gravatar_url text;
|
||||
declare gravatar_status int8;
|
||||
declare email_domain text;
|
||||
declare favicon_url text;
|
||||
declare domain_status int8;
|
||||
|
||||
begin
|
||||
-- Try to fetch a gravatar image
|
||||
email_hash = encode(digest(email, 'sha256'), 'hex');
|
||||
gravatar_url = concat('https://www.gravatar.com/avatar/', email_hash, '?d=404');
|
||||
|
||||
select status from http_get(gravatar_url) into gravatar_status;
|
||||
|
||||
if gravatar_status = 200 then
|
||||
return gravatar_url;
|
||||
end if;
|
||||
|
||||
-- Fallback to email's domain favicon if not excluded
|
||||
email_domain = split_part(email, '@', 2);
|
||||
return get_domain_favicon(email_domain);
|
||||
exception
|
||||
when others then
|
||||
return 'ERROR';
|
||||
end;$function$
|
||||
;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_domain_favicon(domain_name text)
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
AS $function$declare domain_status int8;
|
||||
|
||||
begin
|
||||
if exists (select from favicons_excluded_domains as fav where fav.domain = domain_name) then
|
||||
return null;
|
||||
end if;
|
||||
|
||||
return concat(
|
||||
'https://favicon.show/',
|
||||
(regexp_matches(domain_name, '^(?:https?:\/\/)?(?:[^@\/\n]+@)?(?:www\.)?([^:\/?\n]+)', 'i'))[1]
|
||||
);
|
||||
end;$function$
|
||||
;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.handle_contact_saved()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $function$declare contact_avatar text;
|
||||
declare emails_length int8;
|
||||
declare item jsonb;
|
||||
|
||||
begin
|
||||
if new.avatar is not null then
|
||||
return new;
|
||||
end if;
|
||||
|
||||
select coalesce(jsonb_array_length(new.email_jsonb), 0) into emails_length;
|
||||
|
||||
if emails_length = 0 then
|
||||
return new;
|
||||
end if;
|
||||
|
||||
for item in select jsonb_array_elements(new.email_jsonb)
|
||||
loop
|
||||
select public.get_avatar_for_email(item->>'email') into contact_avatar;
|
||||
if (contact_avatar is not null) then
|
||||
exit;
|
||||
end if;
|
||||
end loop;
|
||||
|
||||
if contact_avatar is null then
|
||||
return new;
|
||||
end if;
|
||||
|
||||
new.avatar = concat('{"src":"', contact_avatar, '"}');
|
||||
return new;
|
||||
end;$function$
|
||||
;
|
||||
|
||||
CREATE TRIGGER contact_saved BEFORE INSERT OR UPDATE ON public.contacts FOR EACH ROW EXECUTE FUNCTION public.handle_contact_saved();
|
||||
|
||||
grant delete on table "public"."favicons_excluded_domains" to "authenticated";
|
||||
|
||||
grant insert on table "public"."favicons_excluded_domains" to "authenticated";
|
||||
|
||||
grant references on table "public"."favicons_excluded_domains" to "authenticated";
|
||||
|
||||
grant select on table "public"."favicons_excluded_domains" to "authenticated";
|
||||
|
||||
grant trigger on table "public"."favicons_excluded_domains" to "authenticated";
|
||||
|
||||
grant truncate on table "public"."favicons_excluded_domains" to "authenticated";
|
||||
|
||||
grant update on table "public"."favicons_excluded_domains" to "authenticated";
|
||||
|
||||
grant delete on table "public"."favicons_excluded_domains" to "postgres";
|
||||
|
||||
grant insert on table "public"."favicons_excluded_domains" to "postgres";
|
||||
|
||||
grant references on table "public"."favicons_excluded_domains" to "postgres";
|
||||
|
||||
grant select on table "public"."favicons_excluded_domains" to "postgres";
|
||||
|
||||
grant trigger on table "public"."favicons_excluded_domains" to "postgres";
|
||||
|
||||
grant truncate on table "public"."favicons_excluded_domains" to "postgres";
|
||||
|
||||
grant update on table "public"."favicons_excluded_domains" to "postgres";
|
||||
|
||||
grant delete on table "public"."favicons_excluded_domains" to "service_role";
|
||||
|
||||
grant insert on table "public"."favicons_excluded_domains" to "service_role";
|
||||
|
||||
grant references on table "public"."favicons_excluded_domains" to "service_role";
|
||||
|
||||
grant select on table "public"."favicons_excluded_domains" to "service_role";
|
||||
|
||||
grant trigger on table "public"."favicons_excluded_domains" to "service_role";
|
||||
|
||||
grant truncate on table "public"."favicons_excluded_domains" to "service_role";
|
||||
|
||||
grant update on table "public"."favicons_excluded_domains" to "service_role";
|
||||
|
||||
|
||||
create policy "Enable access for authenticated users only"
|
||||
on "public"."favicons_excluded_domains"
|
||||
as permissive
|
||||
for all
|
||||
to authenticated
|
||||
using (true)
|
||||
with check (true);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.handle_company_saved()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $function$declare company_logo text;
|
||||
|
||||
begin
|
||||
if new.logo is not null then
|
||||
return new;
|
||||
end if;
|
||||
|
||||
company_logo = get_domain_favicon(new.website);
|
||||
if company_logo is null then
|
||||
return new;
|
||||
end if;
|
||||
|
||||
new.logo = concat('{"src":"', company_logo, '","title":"Company favicon"}');
|
||||
return new;
|
||||
end;$function$
|
||||
;
|
||||
|
||||
CREATE TRIGGER company_saved BEFORE INSERT OR UPDATE ON public.companies FOR EACH ROW EXECUTE FUNCTION public.handle_company_saved();
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.handle_contact_note_created_or_updated()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO ''
|
||||
AS $function$
|
||||
begin
|
||||
update public.contacts set last_seen = new.date where contacts.id = new.contact_id and contacts.last_seen < new.date;
|
||||
return new;
|
||||
end;
|
||||
$function$
|
||||
;
|
||||
|
||||
CREATE TRIGGER on_public_contact_notes_created_or_updated AFTER INSERT ON public.contact_notes FOR EACH ROW EXECUTE FUNCTION public.handle_contact_note_created_or_updated();
|
||||
|
||||
CREATE OR REPLACE FUNCTION get_user_id_by_email(email TEXT)
|
||||
RETURNS TABLE (id uuid)
|
||||
SECURITY definer
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN QUERY SELECT au.id FROM auth.users au WHERE au.email = $1;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
revoke execute on function public.get_user_id_by_email from anon, authenticated, public;
|
||||
51
supabase/migrations/20260128165057_sso_handling.sql
Normal file
51
supabase/migrations/20260128165057_sso_handling.sql
Normal file
@@ -0,0 +1,51 @@
|
||||
create extension if not exists "pgjwt" with schema "extensions";
|
||||
|
||||
alter table "public"."sales" alter column "first_name" set default 'Pending'::text;
|
||||
|
||||
alter table "public"."sales" alter column "last_name" set default 'Pending'::text;
|
||||
|
||||
set check_function_bodies = off;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.handle_new_user()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO ''
|
||||
AS $function$
|
||||
declare
|
||||
sales_count int;
|
||||
begin
|
||||
select count(id) into sales_count
|
||||
from public.sales;
|
||||
|
||||
insert into public.sales (first_name, last_name, email, user_id, administrator)
|
||||
values (
|
||||
coalesce(new.raw_user_meta_data ->> 'first_name', new.raw_user_meta_data -> 'custom_claims' ->> 'first_name', 'Pending'),
|
||||
coalesce(new.raw_user_meta_data ->> 'last_name', new.raw_user_meta_data -> 'custom_claims' ->> 'last_name', 'Pending'),
|
||||
new.email,
|
||||
new.id,
|
||||
case when sales_count > 0 then FALSE else TRUE end
|
||||
);
|
||||
return new;
|
||||
end;
|
||||
$function$
|
||||
;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.handle_update_user()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO ''
|
||||
AS $function$
|
||||
begin
|
||||
update public.sales
|
||||
set
|
||||
first_name = coalesce(new.raw_user_meta_data ->> 'first_name', new.raw_user_meta_data -> 'custom_claims' ->> 'first_name', 'Pending'),
|
||||
last_name = coalesce(new.raw_user_meta_data ->> 'last_name', new.raw_user_meta_data -> 'custom_claims' ->> 'last_name', 'Pending'),
|
||||
email = new.email
|
||||
where user_id = new.id;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$function$
|
||||
;
|
||||
77
supabase/migrations/20260211194545_app_configuration.sql
Normal file
77
supabase/migrations/20260211194545_app_configuration.sql
Normal file
@@ -0,0 +1,77 @@
|
||||
-- Singleton table storing the app configuration as a JSONB document.
|
||||
-- Admins can customize branding, company sectors, deal stages/categories,
|
||||
-- note statuses, and task types via the Settings page.
|
||||
-- The app merges stored values with code defaults, so missing keys are safe.
|
||||
|
||||
create table "public"."configuration" (
|
||||
"id" integer not null default 1,
|
||||
"config" jsonb not null default '{}'::jsonb,
|
||||
constraint "configuration_pkey" primary key ("id"),
|
||||
constraint "configuration_singleton" check ("id" = 1)
|
||||
);
|
||||
|
||||
alter table "public"."configuration" enable row level security;
|
||||
|
||||
-- Helper to check admin status in RLS policies
|
||||
create or replace function public.is_admin()
|
||||
returns boolean language plpgsql security definer set search_path = '' as $$
|
||||
begin
|
||||
return exists (
|
||||
select 1 from public.sales where user_id = auth.uid() and administrator = true
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- All authenticated users can read
|
||||
create policy "Enable read for authenticated" on "public"."configuration"
|
||||
as permissive for select to authenticated using (true);
|
||||
|
||||
-- Only admins can insert/update
|
||||
create policy "Enable insert for admins" on "public"."configuration"
|
||||
as permissive for insert to authenticated with check (public.is_admin());
|
||||
create policy "Enable update for admins" on "public"."configuration"
|
||||
as permissive for update to authenticated
|
||||
using (public.is_admin()) with check (public.is_admin());
|
||||
|
||||
-- Grants
|
||||
grant select, insert, update on "public"."configuration" to "authenticated";
|
||||
grant select, insert, update on "public"."configuration" to "service_role";
|
||||
|
||||
-- Seed empty config (code defaults apply)
|
||||
insert into "public"."configuration" ("id", "config") values (1, '{}'::jsonb);
|
||||
|
||||
-- Migrate old label-based values to slug-based values.
|
||||
--
|
||||
-- Previously, companySectors, dealCategories, and taskTypes were stored as
|
||||
-- plain string arrays (e.g. ["Energy", "Copywriting"]) in individual records.
|
||||
-- Now they use a { value, label } format where `value` is a slug derived from
|
||||
-- the label.
|
||||
--
|
||||
-- This converts record fields (companies.sector, deals.category, tasks.type,
|
||||
-- contact_notes.status) from display labels to slugs.
|
||||
|
||||
CREATE OR REPLACE FUNCTION pg_temp.to_slug(label text) RETURNS text AS $$
|
||||
BEGIN
|
||||
RETURN TRIM(BOTH '-' FROM REGEXP_REPLACE(LOWER(label), '[^a-z0-9]+', '-', 'g'));
|
||||
END;
|
||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||
|
||||
UPDATE companies
|
||||
SET sector = pg_temp.to_slug(sector)
|
||||
WHERE sector IS NOT NULL
|
||||
AND sector != pg_temp.to_slug(sector);
|
||||
|
||||
UPDATE deals
|
||||
SET category = pg_temp.to_slug(category)
|
||||
WHERE category IS NOT NULL
|
||||
AND category != pg_temp.to_slug(category);
|
||||
|
||||
UPDATE tasks
|
||||
SET type = pg_temp.to_slug(type)
|
||||
WHERE type IS NOT NULL
|
||||
AND type != pg_temp.to_slug(type);
|
||||
|
||||
UPDATE contact_notes
|
||||
SET status = pg_temp.to_slug(status)
|
||||
WHERE status IS NOT NULL
|
||||
AND status != pg_temp.to_slug(status);
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Migrating from timestamp with timezone to date only for expected_closing_date as we don't need the precision
|
||||
-- And by only handling the date part in the client it caused conversion issues for users in different timezones
|
||||
ALTER table "public"."deals" ALTER COLUMN "expected_closing_date" TYPE date USING ("expected_closing_date" AT TIME ZONE 'UTC')::date;
|
||||
118
supabase/migrations/20260304104600_note_attachments_trigger.sql
Normal file
118
supabase/migrations/20260304104600_note_attachments_trigger.sql
Normal file
@@ -0,0 +1,118 @@
|
||||
-- Deletes attachment files from storage when note attachments are removed,
|
||||
-- either by deleting a note or by updating a note attachments list.
|
||||
CREATE EXTENSION IF NOT EXISTS pg_net;
|
||||
|
||||
DO $migration$
|
||||
BEGIN
|
||||
EXECUTE $url_sql$
|
||||
-- SQL triggers cannot read Edge Function env vars directly. Resolve the
|
||||
-- function URL from request context, and keep a local Docker fallback.
|
||||
CREATE OR REPLACE FUNCTION public.get_note_attachments_function_url()
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
DECLARE
|
||||
issuer text;
|
||||
function_url text;
|
||||
BEGIN
|
||||
issuer := coalesce(
|
||||
nullif(current_setting('request.jwt.claim.iss', true), ''),
|
||||
(
|
||||
coalesce(
|
||||
nullif(current_setting('request.jwt.claims', true), ''),
|
||||
'{}'
|
||||
)::jsonb ->> 'iss'
|
||||
)
|
||||
);
|
||||
issuer := nullif(issuer, '');
|
||||
IF issuer IS NOT NULL THEN
|
||||
issuer := rtrim(issuer, '/');
|
||||
IF right(issuer, 8) = '/auth/v1' THEN
|
||||
function_url :=
|
||||
left(issuer, length(issuer) - 8) || '/functions/v1/delete_note_attachments';
|
||||
|
||||
IF function_url LIKE 'http://127.0.0.1:%' THEN
|
||||
RETURN replace(
|
||||
function_url,
|
||||
'http://127.0.0.1:',
|
||||
'http://host.docker.internal:'
|
||||
);
|
||||
END IF;
|
||||
|
||||
IF function_url LIKE 'http://localhost:%' THEN
|
||||
RETURN replace(
|
||||
function_url,
|
||||
'http://localhost:',
|
||||
'http://host.docker.internal:'
|
||||
);
|
||||
END IF;
|
||||
|
||||
RETURN function_url;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN 'http://host.docker.internal:54321/functions/v1/delete_note_attachments';
|
||||
END;
|
||||
$function$;
|
||||
$url_sql$;
|
||||
|
||||
EXECUTE $cleanup_sql$
|
||||
CREATE OR REPLACE FUNCTION public.cleanup_note_attachments()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO ''
|
||||
AS $function$
|
||||
DECLARE
|
||||
payload jsonb;
|
||||
request_headers jsonb;
|
||||
auth_header text;
|
||||
BEGIN
|
||||
request_headers := coalesce(
|
||||
nullif(current_setting('request.headers', true), '')::jsonb,
|
||||
'{}'::jsonb
|
||||
);
|
||||
auth_header := request_headers ->> 'authorization';
|
||||
|
||||
IF auth_header IS NULL OR auth_header = '' THEN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
payload := jsonb_build_object(
|
||||
'old_record', OLD,
|
||||
'record', NEW,
|
||||
'type', TG_OP
|
||||
);
|
||||
|
||||
PERFORM net.http_post(
|
||||
url := public.get_note_attachments_function_url(),
|
||||
body := payload,
|
||||
params := '{}'::jsonb,
|
||||
headers := jsonb_build_object(
|
||||
'Content-Type',
|
||||
'application/json',
|
||||
'Authorization',
|
||||
auth_header
|
||||
),
|
||||
timeout_milliseconds := 10000
|
||||
);
|
||||
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$function$;
|
||||
$cleanup_sql$;
|
||||
|
||||
EXECUTE 'CREATE TRIGGER on_contact_notes_deleted_delete_note_attachments AFTER DELETE ON public.contact_notes FOR EACH ROW EXECUTE FUNCTION public.cleanup_note_attachments()';
|
||||
EXECUTE 'CREATE TRIGGER on_deal_notes_deleted_delete_note_attachments AFTER DELETE ON public.deal_notes FOR EACH ROW EXECUTE FUNCTION public.cleanup_note_attachments()';
|
||||
EXECUTE 'CREATE TRIGGER on_contact_notes_attachments_updated_delete_note_attachments AFTER UPDATE ON public.contact_notes FOR EACH ROW WHEN (OLD.attachments IS DISTINCT FROM NEW.attachments) EXECUTE FUNCTION public.cleanup_note_attachments()';
|
||||
EXECUTE 'CREATE TRIGGER on_deal_notes_attachments_updated_delete_note_attachments AFTER UPDATE ON public.deal_notes FOR EACH ROW WHEN (OLD.attachments IS DISTINCT FROM NEW.attachments) EXECUTE FUNCTION public.cleanup_note_attachments()';
|
||||
END;
|
||||
$migration$;
|
||||
18
supabase/migrations/20260307120000_nb_tasks_pending_only.sql
Normal file
18
supabase/migrations/20260307120000_nb_tasks_pending_only.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
drop view if exists contacts_summary;
|
||||
|
||||
create view contacts_summary
|
||||
as
|
||||
select
|
||||
co.*,
|
||||
jsonb_path_query_array(co.email_jsonb, '$[*].email')::text as email_fts,
|
||||
jsonb_path_query_array(co.phone_jsonb, '$[*].number')::text as phone_fts,
|
||||
c.name as company_name,
|
||||
count(distinct t.id) filter (where t.done_date is null) as nb_tasks
|
||||
from
|
||||
contacts co
|
||||
left join
|
||||
tasks t on co.id = t.contact_id
|
||||
left join
|
||||
companies c on co.company_id = c.id
|
||||
group by
|
||||
co.id, c.name;
|
||||
344
supabase/migrations/20260309112831_fix_security_warnings.sql
Normal file
344
supabase/migrations/20260309112831_fix_security_warnings.sql
Normal file
@@ -0,0 +1,344 @@
|
||||
-- This migration allows to resolve several security warnings raised by the Supabase Advisor:
|
||||
-- * Security Definer View
|
||||
-- * Function Search Path Mutable
|
||||
-- * Extension in Public
|
||||
|
||||
-- Recreate the contacts_summary view with security_invoker enabled
|
||||
drop view contacts_summary;
|
||||
|
||||
create view contacts_summary
|
||||
with (security_invoker=on)
|
||||
as
|
||||
select
|
||||
co.*,
|
||||
jsonb_path_query_array(co.email_jsonb, '$[*].email')::text as email_fts,
|
||||
jsonb_path_query_array(co.phone_jsonb, '$[*].number')::text as phone_fts,
|
||||
c.name as company_name,
|
||||
count(distinct t.id) filter (where t.done_date is null) as nb_tasks
|
||||
from
|
||||
contacts co
|
||||
left join
|
||||
tasks t on co.id = t.contact_id
|
||||
left join
|
||||
companies c on co.company_id = c.id
|
||||
group by
|
||||
co.id, c.name;
|
||||
|
||||
-- Ensure all functions have search_path set to prevent search path injection attacks
|
||||
|
||||
-- Functions that already use qualified names can be simply altered to use public search_path
|
||||
|
||||
ALTER FUNCTION public.get_note_attachments_function_url()
|
||||
SET search_path TO 'public';
|
||||
|
||||
ALTER FUNCTION public.get_user_id_by_email(text)
|
||||
SET search_path TO 'public';
|
||||
|
||||
ALTER FUNCTION public.handle_contact_saved()
|
||||
SET search_path TO 'public';
|
||||
|
||||
-- get_avatar_for_email: set search_path='public' and qualify extension calls
|
||||
-- (http_get -> extensions.http_get, digest -> extensions.digest)
|
||||
DROP FUNCTION IF EXISTS public.get_avatar_for_email(text);
|
||||
|
||||
CREATE FUNCTION public.get_avatar_for_email(email text)
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
declare email_hash text;
|
||||
declare gravatar_url text;
|
||||
declare gravatar_status int8;
|
||||
declare email_domain text;
|
||||
declare favicon_url text;
|
||||
declare domain_status int8;
|
||||
|
||||
begin
|
||||
-- Try to fetch a gravatar image
|
||||
email_hash = encode(extensions.digest(email, 'sha256'), 'hex');
|
||||
gravatar_url = concat('https://www.gravatar.com/avatar/', email_hash, '?d=404');
|
||||
|
||||
select status from extensions.http_get(gravatar_url) into gravatar_status;
|
||||
|
||||
if gravatar_status = 200 then
|
||||
return gravatar_url;
|
||||
end if;
|
||||
|
||||
-- Fallback to email's domain favicon if not excluded
|
||||
email_domain = split_part(email, '@', 2);
|
||||
return get_domain_favicon(email_domain);
|
||||
exception
|
||||
when others then
|
||||
return 'ERROR';
|
||||
end;
|
||||
$function$;
|
||||
|
||||
-- get_domain_favicon: set search_path='public' (no changes to table references needed)
|
||||
DROP FUNCTION IF EXISTS public.get_domain_favicon(text);
|
||||
|
||||
CREATE FUNCTION public.get_domain_favicon(domain_name text)
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
declare domain_status int8;
|
||||
|
||||
begin
|
||||
if exists (select from favicons_excluded_domains as fav where fav.domain = domain_name) then
|
||||
return null;
|
||||
end if;
|
||||
|
||||
return concat(
|
||||
'https://favicon.show/',
|
||||
(regexp_matches(domain_name, '^(?:https?:\/\/)?(?:[^@\/\n]+@)?(?:www\.)?([^:\/?\n]+)', 'i'))[1]
|
||||
);
|
||||
end;
|
||||
$function$;
|
||||
|
||||
-- handle_company_saved: set search_path='public' (no changes to function calls needed)
|
||||
-- Drop the trigger first, then the function
|
||||
DROP TRIGGER IF EXISTS company_saved ON public.companies;
|
||||
DROP FUNCTION IF EXISTS public.handle_company_saved();
|
||||
|
||||
CREATE FUNCTION public.handle_company_saved()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
declare company_logo text;
|
||||
|
||||
begin
|
||||
if new.logo is not null then
|
||||
return new;
|
||||
end if;
|
||||
|
||||
company_logo = get_domain_favicon(new.website);
|
||||
if company_logo is null then
|
||||
return new;
|
||||
end if;
|
||||
|
||||
new.logo = concat('{"src":"', company_logo, '","title":"Company favicon"}');
|
||||
return new;
|
||||
end;
|
||||
$function$;
|
||||
|
||||
-- Recreate the trigger
|
||||
CREATE TRIGGER company_saved BEFORE INSERT OR UPDATE ON public.companies FOR EACH ROW EXECUTE FUNCTION public.handle_company_saved();
|
||||
|
||||
-- set_sales_id_default: set search_path='public' (no changes to table references needed)
|
||||
-- Drop all triggers that depend on this function first
|
||||
DROP TRIGGER IF EXISTS set_task_sales_id_trigger ON public.tasks;
|
||||
DROP TRIGGER IF EXISTS set_contact_sales_id_trigger ON public.contacts;
|
||||
DROP TRIGGER IF EXISTS set_contact_notes_sales_id_trigger ON public.contact_notes;
|
||||
DROP TRIGGER IF EXISTS set_company_sales_id_trigger ON public.companies;
|
||||
DROP TRIGGER IF EXISTS set_deal_sales_id_trigger ON public.deals;
|
||||
DROP TRIGGER IF EXISTS set_deal_notes_sales_id_trigger ON public.deal_notes;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.set_sales_id_default();
|
||||
|
||||
CREATE FUNCTION public.set_sales_id_default()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.sales_id IS NULL THEN
|
||||
SELECT id INTO NEW.sales_id FROM sales WHERE user_id = auth.uid();
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Recreate all triggers
|
||||
CREATE TRIGGER set_task_sales_id_trigger
|
||||
BEFORE INSERT ON public.tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.set_sales_id_default();
|
||||
|
||||
CREATE TRIGGER set_contact_sales_id_trigger
|
||||
BEFORE INSERT ON public.contacts
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.set_sales_id_default();
|
||||
|
||||
CREATE TRIGGER set_contact_notes_sales_id_trigger
|
||||
BEFORE INSERT ON public.contact_notes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.set_sales_id_default();
|
||||
|
||||
CREATE TRIGGER set_company_sales_id_trigger
|
||||
BEFORE INSERT ON public.companies
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.set_sales_id_default();
|
||||
|
||||
CREATE TRIGGER set_deal_sales_id_trigger
|
||||
BEFORE INSERT ON public.deals
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.set_sales_id_default();
|
||||
|
||||
CREATE TRIGGER set_deal_notes_sales_id_trigger
|
||||
BEFORE INSERT ON public.deal_notes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.set_sales_id_default();
|
||||
|
||||
-- merge_contacts: set search_path='public' (no changes to table references needed)
|
||||
DROP FUNCTION IF EXISTS public.merge_contacts(bigint, bigint);
|
||||
|
||||
CREATE FUNCTION public.merge_contacts(loser_id bigint, winner_id bigint)
|
||||
RETURNS bigint
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
winner_contact contacts%ROWTYPE;
|
||||
loser_contact contacts%ROWTYPE;
|
||||
deal_record RECORD;
|
||||
merged_emails jsonb;
|
||||
merged_phones jsonb;
|
||||
merged_tags bigint[];
|
||||
winner_emails jsonb;
|
||||
loser_emails jsonb;
|
||||
winner_phones jsonb;
|
||||
loser_phones jsonb;
|
||||
email_map jsonb;
|
||||
phone_map jsonb;
|
||||
BEGIN
|
||||
-- Fetch both contacts
|
||||
SELECT * INTO winner_contact FROM contacts WHERE id = winner_id;
|
||||
SELECT * INTO loser_contact FROM contacts WHERE id = loser_id;
|
||||
|
||||
IF winner_contact IS NULL OR loser_contact IS NULL THEN
|
||||
RAISE EXCEPTION 'Contact not found';
|
||||
END IF;
|
||||
|
||||
-- 1. Reassign tasks from loser to winner
|
||||
UPDATE tasks SET contact_id = winner_id WHERE contact_id = loser_id;
|
||||
|
||||
-- 2. Reassign contact notes from loser to winner
|
||||
UPDATE contact_notes SET contact_id = winner_id WHERE contact_id = loser_id;
|
||||
|
||||
-- 3. Update deals - replace loser with winner in contact_ids array
|
||||
FOR deal_record IN
|
||||
SELECT id, contact_ids
|
||||
FROM deals
|
||||
WHERE contact_ids @> ARRAY[loser_id]
|
||||
LOOP
|
||||
UPDATE deals
|
||||
SET contact_ids = (
|
||||
SELECT ARRAY(
|
||||
SELECT DISTINCT unnest(
|
||||
array_remove(deal_record.contact_ids, loser_id) || ARRAY[winner_id]
|
||||
)
|
||||
)
|
||||
)
|
||||
WHERE id = deal_record.id;
|
||||
END LOOP;
|
||||
|
||||
-- 4. Merge contact data
|
||||
|
||||
-- Get email arrays
|
||||
winner_emails := COALESCE(winner_contact.email_jsonb, '[]'::jsonb);
|
||||
loser_emails := COALESCE(loser_contact.email_jsonb, '[]'::jsonb);
|
||||
|
||||
-- Merge emails with deduplication by email address
|
||||
-- Build a map of email -> email object, then convert back to array
|
||||
email_map := '{}'::jsonb;
|
||||
|
||||
-- Add winner emails to map
|
||||
IF jsonb_array_length(winner_emails) > 0 THEN
|
||||
FOR i IN 0..jsonb_array_length(winner_emails)-1 LOOP
|
||||
email_map := email_map || jsonb_build_object(
|
||||
winner_emails->i->>'email',
|
||||
winner_emails->i
|
||||
);
|
||||
END LOOP;
|
||||
END IF;
|
||||
|
||||
-- Add loser emails to map (won't overwrite existing keys)
|
||||
IF jsonb_array_length(loser_emails) > 0 THEN
|
||||
FOR i IN 0..jsonb_array_length(loser_emails)-1 LOOP
|
||||
IF NOT email_map ? (loser_emails->i->>'email') THEN
|
||||
email_map := email_map || jsonb_build_object(
|
||||
loser_emails->i->>'email',
|
||||
loser_emails->i
|
||||
);
|
||||
END IF;
|
||||
END LOOP;
|
||||
END IF;
|
||||
|
||||
-- Convert map back to array
|
||||
merged_emails := (SELECT jsonb_agg(value) FROM jsonb_each(email_map));
|
||||
merged_emails := COALESCE(merged_emails, '[]'::jsonb);
|
||||
|
||||
-- Get phone arrays
|
||||
winner_phones := COALESCE(winner_contact.phone_jsonb, '[]'::jsonb);
|
||||
loser_phones := COALESCE(loser_contact.phone_jsonb, '[]'::jsonb);
|
||||
|
||||
-- Merge phones with deduplication by number
|
||||
phone_map := '{}'::jsonb;
|
||||
|
||||
-- Add winner phones to map
|
||||
IF jsonb_array_length(winner_phones) > 0 THEN
|
||||
FOR i IN 0..jsonb_array_length(winner_phones)-1 LOOP
|
||||
phone_map := phone_map || jsonb_build_object(
|
||||
winner_phones->i->>'number',
|
||||
winner_phones->i
|
||||
);
|
||||
END LOOP;
|
||||
END IF;
|
||||
|
||||
-- Add loser phones to map (won't overwrite existing keys)
|
||||
IF jsonb_array_length(loser_phones) > 0 THEN
|
||||
FOR i IN 0..jsonb_array_length(loser_phones)-1 LOOP
|
||||
IF NOT phone_map ? (loser_phones->i->>'number') THEN
|
||||
phone_map := phone_map || jsonb_build_object(
|
||||
loser_phones->i->>'number',
|
||||
loser_phones->i
|
||||
);
|
||||
END IF;
|
||||
END LOOP;
|
||||
END IF;
|
||||
|
||||
-- Convert map back to array
|
||||
merged_phones := (SELECT jsonb_agg(value) FROM jsonb_each(phone_map));
|
||||
merged_phones := COALESCE(merged_phones, '[]'::jsonb);
|
||||
|
||||
-- Merge tags (remove duplicates)
|
||||
merged_tags := ARRAY(
|
||||
SELECT DISTINCT unnest(
|
||||
COALESCE(winner_contact.tags, ARRAY[]::bigint[]) ||
|
||||
COALESCE(loser_contact.tags, ARRAY[]::bigint[])
|
||||
)
|
||||
);
|
||||
|
||||
-- 5. Update winner with merged data
|
||||
UPDATE contacts SET
|
||||
avatar = COALESCE(winner_contact.avatar, loser_contact.avatar),
|
||||
gender = COALESCE(winner_contact.gender, loser_contact.gender),
|
||||
first_name = COALESCE(winner_contact.first_name, loser_contact.first_name),
|
||||
last_name = COALESCE(winner_contact.last_name, loser_contact.last_name),
|
||||
title = COALESCE(winner_contact.title, loser_contact.title),
|
||||
company_id = COALESCE(winner_contact.company_id, loser_contact.company_id),
|
||||
email_jsonb = merged_emails,
|
||||
phone_jsonb = merged_phones,
|
||||
linkedin_url = COALESCE(winner_contact.linkedin_url, loser_contact.linkedin_url),
|
||||
background = COALESCE(winner_contact.background, loser_contact.background),
|
||||
has_newsletter = COALESCE(winner_contact.has_newsletter, loser_contact.has_newsletter),
|
||||
first_seen = LEAST(COALESCE(winner_contact.first_seen, loser_contact.first_seen), COALESCE(loser_contact.first_seen, winner_contact.first_seen)),
|
||||
last_seen = GREATEST(COALESCE(winner_contact.last_seen, loser_contact.last_seen), COALESCE(loser_contact.last_seen, winner_contact.last_seen)),
|
||||
sales_id = COALESCE(winner_contact.sales_id, loser_contact.sales_id),
|
||||
tags = merged_tags
|
||||
WHERE id = winner_id;
|
||||
|
||||
-- 6. Delete loser contact
|
||||
DELETE FROM contacts WHERE id = loser_id;
|
||||
|
||||
RETURN winner_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Move pg_net extension to extensions schema (security requirement)
|
||||
-- pg_net doesn't support ALTER EXTENSION SET SCHEMA, so drop and recreate
|
||||
DROP EXTENSION IF EXISTS pg_net CASCADE;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_net WITH SCHEMA extensions;
|
||||
|
||||
79
supabase/migrations/20260314120000_activity_log_view.sql
Normal file
79
supabase/migrations/20260314120000_activity_log_view.sql
Normal file
@@ -0,0 +1,79 @@
|
||||
-- Create a view that aggregates all activity events into a single, pageable list.
|
||||
-- Each row represents one event (company created, contact created, note created, etc.)
|
||||
-- sorted by date so PostgREST can paginate with Range headers.
|
||||
|
||||
create view "public"."activity_log"
|
||||
with (security_invoker=on)
|
||||
as
|
||||
SELECT
|
||||
'company.' || c.id || '.created' as id,
|
||||
'company.created' as type,
|
||||
c.created_at as date,
|
||||
c.id as company_id,
|
||||
c.sales_id,
|
||||
to_json(c.*) as company,
|
||||
NULL::json as contact,
|
||||
NULL::json as deal,
|
||||
NULL::json as contact_note,
|
||||
NULL::json as deal_note
|
||||
FROM companies c
|
||||
UNION ALL
|
||||
SELECT
|
||||
'contact.' || co.id || '.created',
|
||||
'contact.created',
|
||||
co.first_seen,
|
||||
co.company_id,
|
||||
co.sales_id,
|
||||
NULL::json,
|
||||
to_json(co.*),
|
||||
NULL::json,
|
||||
NULL::json,
|
||||
NULL::json
|
||||
FROM contacts co
|
||||
UNION ALL
|
||||
SELECT
|
||||
'contactNote.' || cn.id || '.created',
|
||||
'contactNote.created',
|
||||
cn.date,
|
||||
co.company_id,
|
||||
cn.sales_id,
|
||||
NULL::json,
|
||||
NULL::json,
|
||||
NULL::json,
|
||||
to_json(cn.*),
|
||||
NULL::json
|
||||
FROM contact_notes cn
|
||||
LEFT JOIN contacts co ON co.id = cn.contact_id
|
||||
UNION ALL
|
||||
SELECT
|
||||
'deal.' || d.id || '.created',
|
||||
'deal.created',
|
||||
d.created_at,
|
||||
d.company_id,
|
||||
d.sales_id,
|
||||
NULL::json,
|
||||
NULL::json,
|
||||
to_json(d.*),
|
||||
NULL::json,
|
||||
NULL::json
|
||||
FROM deals d
|
||||
UNION ALL
|
||||
SELECT
|
||||
'dealNote.' || dn.id || '.created',
|
||||
'dealNote.created',
|
||||
dn.date,
|
||||
d.company_id,
|
||||
dn.sales_id,
|
||||
NULL::json,
|
||||
NULL::json,
|
||||
NULL::json,
|
||||
NULL::json,
|
||||
to_json(dn.*)
|
||||
FROM deal_notes dn
|
||||
LEFT JOIN deals d ON d.id = dn.deal_id;
|
||||
|
||||
-- Indexes to support filtering the view by company_id efficiently
|
||||
CREATE INDEX ON contacts (company_id);
|
||||
CREATE INDEX ON deals (company_id);
|
||||
CREATE INDEX ON contact_notes (contact_id);
|
||||
CREATE INDEX ON deal_notes (deal_id);
|
||||
@@ -0,0 +1,86 @@
|
||||
-- Enable citext extension for case-insensitive text columns
|
||||
CREATE EXTENSION IF NOT EXISTS "citext" WITH SCHEMA "extensions";
|
||||
|
||||
-- Drop view that depends on companies.website before altering the column type
|
||||
DROP VIEW IF EXISTS public.companies_summary;
|
||||
|
||||
-- Change companies.website to citext for case-insensitive comparisons
|
||||
ALTER TABLE companies ALTER COLUMN website TYPE extensions.citext;
|
||||
|
||||
-- Recreate companies_summary view
|
||||
CREATE OR REPLACE VIEW public.companies_summary WITH (security_invoker = on) AS
|
||||
SELECT
|
||||
c.id,
|
||||
c.created_at,
|
||||
c.name,
|
||||
c.sector,
|
||||
c.size,
|
||||
c.linkedin_url,
|
||||
c.website,
|
||||
c.phone_number,
|
||||
c.address,
|
||||
c.zipcode,
|
||||
c.city,
|
||||
c.state_abbr,
|
||||
c.sales_id,
|
||||
c.context_links,
|
||||
c.country,
|
||||
c.description,
|
||||
c.revenue,
|
||||
c.tax_identifier,
|
||||
c.logo,
|
||||
count(distinct d.id) as nb_deals,
|
||||
count(distinct co.id) as nb_contacts
|
||||
FROM public.companies c
|
||||
LEFT JOIN public.deals d ON c.id = d.company_id
|
||||
LEFT JOIN public.contacts co ON c.id = co.company_id
|
||||
GROUP BY c.id;
|
||||
|
||||
-- Change sales.email to citext for case-insensitive comparisons
|
||||
ALTER TABLE sales ALTER COLUMN email TYPE extensions.citext;
|
||||
|
||||
-- Function to lowercase email addresses in email_jsonb
|
||||
CREATE OR REPLACE FUNCTION lowercase_email_jsonb()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.email_jsonb IS NOT NULL THEN
|
||||
NEW.email_jsonb = COALESCE((
|
||||
SELECT jsonb_agg(
|
||||
jsonb_set(elem, '{email}', to_jsonb(LOWER(elem->>'email')))
|
||||
)
|
||||
FROM jsonb_array_elements(NEW.email_jsonb) AS elem
|
||||
), '[]'::jsonb);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Drop and recreate triggers to ensure proper ordering
|
||||
-- Lowercase trigger for email_jsonb must run before the avatar trigger
|
||||
|
||||
-- Drop existing triggers
|
||||
DROP TRIGGER IF EXISTS contact_saved ON contacts;
|
||||
|
||||
-- Create lowercase trigger first (so it runs before contact_saved)
|
||||
CREATE TRIGGER "10_lowercase_contact_emails"
|
||||
BEFORE INSERT OR UPDATE ON contacts
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION lowercase_email_jsonb();
|
||||
|
||||
-- Recreate contact_saved trigger (will run after lowercase_contact_emails)
|
||||
CREATE TRIGGER "20_contact_saved"
|
||||
BEFORE INSERT OR UPDATE ON contacts
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION handle_contact_saved();
|
||||
|
||||
UPDATE contacts
|
||||
SET email_jsonb = COALESCE((
|
||||
SELECT jsonb_agg(
|
||||
jsonb_set(elem, '{email}', to_jsonb(LOWER(elem->>'email')))
|
||||
)
|
||||
FROM jsonb_array_elements(email_jsonb) AS elem
|
||||
), '[]'::jsonb)
|
||||
WHERE email_jsonb IS NOT NULL;
|
||||
@@ -0,0 +1,33 @@
|
||||
-- The CRM's public views (activity_log, companies_summary, contacts_summary
|
||||
-- and init_state) are queried through the Supabase Data API by the anon and
|
||||
-- authenticated roles. `init_state` in particular is read by the anon role
|
||||
-- *before* login (see src/components/atomic-crm/providers/supabase/authProvider.ts)
|
||||
-- to decide whether the CRM has already been initialized.
|
||||
--
|
||||
-- Unlike the tables -- whose grants were captured in earlier migrations -- these
|
||||
-- views never had explicit grants in a migration: they were reachable only
|
||||
-- because Supabase used to grant every object in the `public` schema to the
|
||||
-- anon/authenticated/service_role API roles automatically. That behavior has
|
||||
-- been removed: objects are no longer exposed to the Data/GraphQL API unless
|
||||
-- access is granted explicitly.
|
||||
-- https://supabase.com/changelog/45329-breaking-change-tables-not-exposed-to-data-and-graphql-api-automatically
|
||||
--
|
||||
-- These grants are already declared in supabase/schemas/06_grants.sql (the
|
||||
-- source of truth) but were never applied through a migration. Re-declare them
|
||||
-- explicitly so production keeps exposing the views.
|
||||
|
||||
grant all on table public.activity_log to anon;
|
||||
grant all on table public.activity_log to authenticated;
|
||||
grant all on table public.activity_log to service_role;
|
||||
|
||||
grant all on table public.companies_summary to anon;
|
||||
grant all on table public.companies_summary to authenticated;
|
||||
grant all on table public.companies_summary to service_role;
|
||||
|
||||
grant all on table public.contacts_summary to anon;
|
||||
grant all on table public.contacts_summary to authenticated;
|
||||
grant all on table public.contacts_summary to service_role;
|
||||
|
||||
grant all on table public.init_state to anon;
|
||||
grant all on table public.init_state to authenticated;
|
||||
grant all on table public.init_state to service_role;
|
||||
@@ -0,0 +1,3 @@
|
||||
alter table "public"."sales" add column "secondary_emails" jsonb not null default '[]'::jsonb;
|
||||
|
||||
alter table "public"."sales" add constraint "sales_secondary_emails_is_array" check (jsonb_typeof(secondary_emails) = 'array');
|
||||
Reference in New Issue
Block a user