realtimex-crm 0.8.1 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/realtimex-crm.js +56 -32
- package/dist/assets/{DealList-B3alafQv.js → DealList-DbwJCRGl.js} +2 -2
- package/dist/assets/{DealList-B3alafQv.js.map → DealList-DbwJCRGl.js.map} +1 -1
- package/dist/assets/index-C__S90Gb.css +1 -0
- package/dist/assets/index-mE-upBfc.js +166 -0
- package/dist/assets/index-mE-upBfc.js.map +1 -0
- package/dist/index.html +1 -1
- package/dist/stats.html +1 -1
- package/package.json +3 -1
- package/src/components/atomic-crm/activities/ActivitiesPage.tsx +16 -0
- package/src/components/atomic-crm/activities/ActivityFeed.tsx +212 -0
- package/src/components/atomic-crm/activities/FileUpload.tsx +359 -0
- package/src/components/atomic-crm/contacts/ContactShow.tsx +28 -10
- package/src/components/atomic-crm/integrations/ApiKeysTab.tsx +184 -0
- package/src/components/atomic-crm/integrations/CreateApiKeyDialog.tsx +217 -0
- package/src/components/atomic-crm/integrations/CreateChannelDialog.tsx +139 -0
- package/src/components/atomic-crm/integrations/IngestionChannelsTab.tsx +188 -0
- package/src/components/atomic-crm/integrations/IntegrationsPage.tsx +46 -0
- package/src/components/atomic-crm/integrations/WebhooksTab.tsx +402 -0
- package/src/components/atomic-crm/layout/Header.tsx +14 -1
- package/src/components/atomic-crm/root/CRM.tsx +2 -0
- package/src/components/ui/alert-dialog.tsx +155 -0
- package/src/lib/api-key-utils.ts +22 -0
- package/supabase/fix_webhook_hardcoded.sql +34 -0
- package/supabase/functions/_shared/apiKeyAuth.ts +171 -0
- package/supabase/functions/_shared/ingestionGuard.ts +128 -0
- package/supabase/functions/_shared/utils.ts +1 -1
- package/supabase/functions/_shared/webhookSignature.ts +23 -0
- package/supabase/functions/api-v1-activities/index.ts +137 -0
- package/supabase/functions/api-v1-companies/index.ts +166 -0
- package/supabase/functions/api-v1-contacts/index.ts +171 -0
- package/supabase/functions/api-v1-deals/index.ts +166 -0
- package/supabase/functions/ingest-activity/.well-known/supabase/config.toml +4 -0
- package/supabase/functions/ingest-activity/index.ts +261 -0
- package/supabase/functions/webhook-dispatcher/index.ts +133 -0
- package/supabase/migrations/20251219120000_api_integrations.sql +133 -0
- package/supabase/migrations/20251219120100_webhook_triggers.sql +176 -0
- package/supabase/migrations/20251219120200_webhook_cron.sql +26 -0
- package/supabase/migrations/20251220120000_realtime_ingestion.sql +154 -0
- package/supabase/migrations/20251221000000_contact_matching.sql +94 -0
- package/supabase/migrations/20251221000001_fix_ingestion_providers_rls.sql +23 -0
- package/supabase/migrations/20251221000002_fix_contact_matching_jsonb.sql +67 -0
- package/supabase/migrations/20251221000003_fix_email_matching.sql +70 -0
- package/supabase/migrations/20251221000004_time_based_work_stealing.sql +99 -0
- package/supabase/migrations/20251221000005_realtime_functions.sql +73 -0
- package/supabase/migrations/20251221000006_enable_pg_net.sql +3 -0
- package/supabase/migrations/20251222075019_enable_extensions_and_configure_cron.sql +86 -0
- package/supabase/migrations/20251222094036_large_payload_storage.sql +213 -0
- package/supabase/migrations/20251222094247_large_payload_cron.sql +28 -0
- package/supabase/migrations/20251222220000_fix_large_payload_types.sql +72 -0
- package/supabase/migrations/20251223000000_enable_realtime_all_crm_tables.sql +50 -0
- package/supabase/migrations/20251223185638_remove_large_payload_storage.sql +54 -0
- package/dist/assets/index-CHk72bAf.css +0 -1
- package/dist/assets/index-mhAjQ1_w.js +0 -153
- package/dist/assets/index-mhAjQ1_w.js.map +0 -1
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
-- Function to enqueue webhook events
|
|
2
|
+
create or replace function enqueue_webhook_event(
|
|
3
|
+
p_event_type text,
|
|
4
|
+
p_payload jsonb
|
|
5
|
+
) returns void
|
|
6
|
+
language plpgsql
|
|
7
|
+
security definer
|
|
8
|
+
set search_path = public
|
|
9
|
+
as $$
|
|
10
|
+
begin
|
|
11
|
+
-- Find all active webhooks that listen to this event
|
|
12
|
+
insert into public.webhook_queue (webhook_id, event_type, payload, next_retry_at)
|
|
13
|
+
select
|
|
14
|
+
id,
|
|
15
|
+
p_event_type,
|
|
16
|
+
p_payload,
|
|
17
|
+
now()
|
|
18
|
+
from public.webhooks
|
|
19
|
+
where is_active = true
|
|
20
|
+
and p_event_type = any(events);
|
|
21
|
+
end;
|
|
22
|
+
$$;
|
|
23
|
+
|
|
24
|
+
-- Trigger function for contact CRUD events
|
|
25
|
+
create or replace function trigger_contact_webhooks()
|
|
26
|
+
returns trigger
|
|
27
|
+
language plpgsql
|
|
28
|
+
security definer
|
|
29
|
+
set search_path = public
|
|
30
|
+
as $$
|
|
31
|
+
declare
|
|
32
|
+
event_type text;
|
|
33
|
+
payload jsonb;
|
|
34
|
+
begin
|
|
35
|
+
if (TG_OP = 'INSERT') then
|
|
36
|
+
event_type := 'contact.created';
|
|
37
|
+
payload := to_jsonb(NEW);
|
|
38
|
+
elsif (TG_OP = 'UPDATE') then
|
|
39
|
+
event_type := 'contact.updated';
|
|
40
|
+
payload := jsonb_build_object('old', to_jsonb(OLD), 'new', to_jsonb(NEW));
|
|
41
|
+
elsif (TG_OP = 'DELETE') then
|
|
42
|
+
event_type := 'contact.deleted';
|
|
43
|
+
payload := to_jsonb(OLD);
|
|
44
|
+
end if;
|
|
45
|
+
|
|
46
|
+
perform enqueue_webhook_event(event_type, payload);
|
|
47
|
+
|
|
48
|
+
if (TG_OP = 'DELETE') then
|
|
49
|
+
return OLD;
|
|
50
|
+
else
|
|
51
|
+
return NEW;
|
|
52
|
+
end if;
|
|
53
|
+
end;
|
|
54
|
+
$$;
|
|
55
|
+
|
|
56
|
+
-- Trigger function for company CRUD events
|
|
57
|
+
create or replace function trigger_company_webhooks()
|
|
58
|
+
returns trigger
|
|
59
|
+
language plpgsql
|
|
60
|
+
security definer
|
|
61
|
+
set search_path = public
|
|
62
|
+
as $$
|
|
63
|
+
declare
|
|
64
|
+
event_type text;
|
|
65
|
+
payload jsonb;
|
|
66
|
+
begin
|
|
67
|
+
if (TG_OP = 'INSERT') then
|
|
68
|
+
event_type := 'company.created';
|
|
69
|
+
payload := to_jsonb(NEW);
|
|
70
|
+
elsif (TG_OP = 'UPDATE') then
|
|
71
|
+
event_type := 'company.updated';
|
|
72
|
+
payload := jsonb_build_object('old', to_jsonb(OLD), 'new', to_jsonb(NEW));
|
|
73
|
+
elsif (TG_OP = 'DELETE') then
|
|
74
|
+
event_type := 'company.deleted';
|
|
75
|
+
payload := to_jsonb(OLD);
|
|
76
|
+
end if;
|
|
77
|
+
|
|
78
|
+
perform enqueue_webhook_event(event_type, payload);
|
|
79
|
+
|
|
80
|
+
if (TG_OP = 'DELETE') then
|
|
81
|
+
return OLD;
|
|
82
|
+
else
|
|
83
|
+
return NEW;
|
|
84
|
+
end if;
|
|
85
|
+
end;
|
|
86
|
+
$$;
|
|
87
|
+
|
|
88
|
+
-- Trigger function for deal CRUD and stage change events
|
|
89
|
+
create or replace function trigger_deal_webhooks()
|
|
90
|
+
returns trigger
|
|
91
|
+
language plpgsql
|
|
92
|
+
security definer
|
|
93
|
+
set search_path = public
|
|
94
|
+
as $$
|
|
95
|
+
declare
|
|
96
|
+
event_type text;
|
|
97
|
+
payload jsonb;
|
|
98
|
+
begin
|
|
99
|
+
if (TG_OP = 'INSERT') then
|
|
100
|
+
event_type := 'deal.created';
|
|
101
|
+
payload := to_jsonb(NEW);
|
|
102
|
+
perform enqueue_webhook_event(event_type, payload);
|
|
103
|
+
elsif (TG_OP = 'UPDATE') then
|
|
104
|
+
-- Check for stage changes
|
|
105
|
+
if (OLD.stage <> NEW.stage) then
|
|
106
|
+
event_type := 'deal.stage_changed';
|
|
107
|
+
payload := jsonb_build_object(
|
|
108
|
+
'deal_id', NEW.id,
|
|
109
|
+
'old_stage', OLD.stage,
|
|
110
|
+
'new_stage', NEW.stage,
|
|
111
|
+
'deal', to_jsonb(NEW)
|
|
112
|
+
);
|
|
113
|
+
perform enqueue_webhook_event(event_type, payload);
|
|
114
|
+
|
|
115
|
+
-- Check for won/lost
|
|
116
|
+
if (NEW.stage = 'won') then
|
|
117
|
+
perform enqueue_webhook_event('deal.won', to_jsonb(NEW));
|
|
118
|
+
elsif (NEW.stage = 'lost') then
|
|
119
|
+
perform enqueue_webhook_event('deal.lost', to_jsonb(NEW));
|
|
120
|
+
end if;
|
|
121
|
+
end if;
|
|
122
|
+
|
|
123
|
+
event_type := 'deal.updated';
|
|
124
|
+
payload := jsonb_build_object('old', to_jsonb(OLD), 'new', to_jsonb(NEW));
|
|
125
|
+
perform enqueue_webhook_event(event_type, payload);
|
|
126
|
+
elsif (TG_OP = 'DELETE') then
|
|
127
|
+
event_type := 'deal.deleted';
|
|
128
|
+
payload := to_jsonb(OLD);
|
|
129
|
+
perform enqueue_webhook_event(event_type, payload);
|
|
130
|
+
end if;
|
|
131
|
+
|
|
132
|
+
if (TG_OP = 'DELETE') then
|
|
133
|
+
return OLD;
|
|
134
|
+
else
|
|
135
|
+
return NEW;
|
|
136
|
+
end if;
|
|
137
|
+
end;
|
|
138
|
+
$$;
|
|
139
|
+
|
|
140
|
+
-- Trigger function for task completion
|
|
141
|
+
create or replace function trigger_task_webhooks()
|
|
142
|
+
returns trigger
|
|
143
|
+
language plpgsql
|
|
144
|
+
security definer
|
|
145
|
+
set search_path = public
|
|
146
|
+
as $$
|
|
147
|
+
begin
|
|
148
|
+
if (TG_OP = 'UPDATE' and OLD.done_date is null and NEW.done_date is not null) then
|
|
149
|
+
perform enqueue_webhook_event('task.completed', to_jsonb(NEW));
|
|
150
|
+
end if;
|
|
151
|
+
|
|
152
|
+
return NEW;
|
|
153
|
+
end;
|
|
154
|
+
$$;
|
|
155
|
+
|
|
156
|
+
-- Create triggers
|
|
157
|
+
create trigger contact_webhook_trigger
|
|
158
|
+
after insert or update or delete on public.contacts
|
|
159
|
+
for each row execute function trigger_contact_webhooks();
|
|
160
|
+
|
|
161
|
+
create trigger company_webhook_trigger
|
|
162
|
+
after insert or update or delete on public.companies
|
|
163
|
+
for each row execute function trigger_company_webhooks();
|
|
164
|
+
|
|
165
|
+
create trigger deal_webhook_trigger
|
|
166
|
+
after insert or update or delete on public.deals
|
|
167
|
+
for each row execute function trigger_deal_webhooks();
|
|
168
|
+
|
|
169
|
+
create trigger task_webhook_trigger
|
|
170
|
+
after update on public.tasks
|
|
171
|
+
for each row execute function trigger_task_webhooks();
|
|
172
|
+
|
|
173
|
+
-- Grant execute permissions on webhook functions
|
|
174
|
+
grant execute on function enqueue_webhook_event(text, jsonb) to authenticated;
|
|
175
|
+
grant execute on function enqueue_webhook_event(text, jsonb) to service_role;
|
|
176
|
+
grant execute on function enqueue_webhook_event(text, jsonb) to anon;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
-- Enable pg_cron extension if not already enabled
|
|
2
|
+
create extension if not exists pg_cron with schema extensions;
|
|
3
|
+
|
|
4
|
+
-- Schedule webhook dispatcher to run every minute
|
|
5
|
+
-- Note: This uses Supabase's http extension to call the Edge Function
|
|
6
|
+
select cron.schedule(
|
|
7
|
+
'webhook-dispatcher',
|
|
8
|
+
'* * * * *', -- Every minute
|
|
9
|
+
$$
|
|
10
|
+
select
|
|
11
|
+
net.http_post(
|
|
12
|
+
url:='https://' || current_setting('app.settings.supabase_url', true) || '/functions/v1/webhook-dispatcher',
|
|
13
|
+
headers:=jsonb_build_object(
|
|
14
|
+
'Content-Type','application/json',
|
|
15
|
+
'Authorization', 'Bearer ' || current_setting('app.settings.service_role_key', true)
|
|
16
|
+
),
|
|
17
|
+
body:='{}'::jsonb
|
|
18
|
+
) as request_id;
|
|
19
|
+
$$
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
-- Note: To configure this cron job properly, you need to set the following settings in your Supabase project:
|
|
23
|
+
-- ALTER DATABASE postgres SET app.settings.supabase_url = 'your-project-ref.supabase.co';
|
|
24
|
+
-- ALTER DATABASE postgres SET app.settings.service_role_key = 'your-service-role-key';
|
|
25
|
+
--
|
|
26
|
+
-- Alternatively, the webhook dispatcher can be called manually or via a separate cron service like GitHub Actions or a cloud scheduler.
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
-- Migration: Create Activities Table and Ingestion Logic
|
|
2
|
+
|
|
3
|
+
-- 1. Create the 'activities' table (The Unified Event Store)
|
|
4
|
+
create table "public"."activities" (
|
|
5
|
+
"id" uuid not null default gen_random_uuid(),
|
|
6
|
+
"created_at" timestamp with time zone not null default now(),
|
|
7
|
+
"type" text not null check (type in ('email', 'call', 'sms', 'meeting', 'note', 'task', 'whatsapp', 'other')),
|
|
8
|
+
"direction" text not null check (direction in ('inbound', 'outbound')),
|
|
9
|
+
"sales_id" bigint, -- References sales(id). If NULL, it is Global/Unassigned.
|
|
10
|
+
"contact_id" bigint, -- References contacts(id).
|
|
11
|
+
|
|
12
|
+
-- Processing State Machine
|
|
13
|
+
"processing_status" text not null default 'raw' check (processing_status in ('raw', 'processing', 'completed', 'failed')),
|
|
14
|
+
"locked_by" uuid, -- The ID of the generic user (auth.users) or specific agent (sales.id) processing it.
|
|
15
|
+
"locked_at" timestamp with time zone,
|
|
16
|
+
|
|
17
|
+
-- Data Payloads
|
|
18
|
+
"raw_data" jsonb, -- Stores URL or Text content. { "source_type": "url"|"text", "content": "..." }
|
|
19
|
+
"processed_data" jsonb, -- Stores AI results. { "transcript": "...", "summary": "..." }
|
|
20
|
+
"metadata" jsonb, -- Provider info. { "twilio_sid": "...", "from": "..." }
|
|
21
|
+
"provider_id" uuid, -- Link to ingestion_providers(id) for future flexibility
|
|
22
|
+
|
|
23
|
+
constraint "activities_pkey" primary key ("id")
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
-- Enable RLS
|
|
27
|
+
alter table "public"."activities" enable row level security;
|
|
28
|
+
|
|
29
|
+
-- 2. Create the 'ingestion_providers' table (Configuration Registry)
|
|
30
|
+
create table "public"."ingestion_providers" (
|
|
31
|
+
"id" uuid not null default gen_random_uuid(),
|
|
32
|
+
"created_at" timestamp with time zone not null default now(),
|
|
33
|
+
"provider_code" text not null check (provider_code in ('twilio', 'postmark', 'gmail', 'generic')),
|
|
34
|
+
"name" text not null, -- Friendly name e.g. "US Support Line"
|
|
35
|
+
"is_active" boolean not null default true,
|
|
36
|
+
"config" jsonb not null default '{}'::jsonb, -- Encrypted secrets go here
|
|
37
|
+
"sales_id" bigint, -- Default Owner. If NULL, leads are Unassigned.
|
|
38
|
+
"ingestion_key" text unique, -- Public identifier used in Webhook URLs e.g. /ingest?key=xyz
|
|
39
|
+
|
|
40
|
+
constraint "ingestion_providers_pkey" primary key ("id")
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
alter table "public"."ingestion_providers" enable row level security;
|
|
44
|
+
|
|
45
|
+
-- 3. Update 'sales' table with Processing Rules
|
|
46
|
+
alter table "public"."sales"
|
|
47
|
+
add column "stale_threshold_minutes" integer default 15,
|
|
48
|
+
add column "allow_remote_processing" boolean default true;
|
|
49
|
+
|
|
50
|
+
-- 4. Foreign Keys
|
|
51
|
+
alter table "public"."activities"
|
|
52
|
+
add constraint "activities_sales_id_fkey" foreign key ("sales_id") references "public"."sales"("id") on delete set null,
|
|
53
|
+
add constraint "activities_contact_id_fkey" foreign key ("contact_id") references "public"."contacts"("id") on delete cascade,
|
|
54
|
+
add constraint "activities_provider_id_fkey" foreign key ("provider_id") references "public"."ingestion_providers"("id") on delete set null;
|
|
55
|
+
|
|
56
|
+
alter table "public"."ingestion_providers"
|
|
57
|
+
add constraint "ingestion_providers_sales_id_fkey" foreign key ("sales_id") references "public"."sales"("id") on delete set null;
|
|
58
|
+
|
|
59
|
+
-- 5. Indexes for Performance (Crucial for Work Stealing)
|
|
60
|
+
create index "activities_processing_queue_idx" on "public"."activities" ("processing_status", "created_at") where processing_status = 'raw';
|
|
61
|
+
create index "activities_sales_id_idx" on "public"."activities" ("sales_id");
|
|
62
|
+
|
|
63
|
+
-- 6. RPC: The "Work Stealing" Function
|
|
64
|
+
-- Note: p_agent_sales_id must be the BIGINT id from the 'sales' table, not auth.uid()
|
|
65
|
+
create or replace function claim_next_pending_activity(
|
|
66
|
+
p_agent_sales_id bigint
|
|
67
|
+
)
|
|
68
|
+
returns table (
|
|
69
|
+
id uuid,
|
|
70
|
+
raw_data jsonb,
|
|
71
|
+
type text,
|
|
72
|
+
is_global boolean
|
|
73
|
+
)
|
|
74
|
+
language plpgsql
|
|
75
|
+
security definer -- Runs with elevated privileges to update the row
|
|
76
|
+
as $$
|
|
77
|
+
begin
|
|
78
|
+
return query
|
|
79
|
+
update "public"."activities" a
|
|
80
|
+
set
|
|
81
|
+
processing_status = 'processing',
|
|
82
|
+
locked_by = (select user_id from sales where id = p_agent_sales_id), -- Store the Auth UUID for auditing
|
|
83
|
+
locked_at = now()
|
|
84
|
+
from "public"."sales" s_owner
|
|
85
|
+
where a.id = (
|
|
86
|
+
select act.id
|
|
87
|
+
from "public"."activities" act
|
|
88
|
+
left join "public"."sales" owner on act.sales_id = owner.id
|
|
89
|
+
where
|
|
90
|
+
act.processing_status = 'raw'
|
|
91
|
+
and (
|
|
92
|
+
-- CRITERIA 1: IT IS MINE
|
|
93
|
+
act.sales_id = p_agent_sales_id
|
|
94
|
+
|
|
95
|
+
-- CRITERIA 2: IT IS GLOBAL
|
|
96
|
+
or act.sales_id is null
|
|
97
|
+
|
|
98
|
+
-- CRITERIA 3: IT IS STALE AND STEALABLE
|
|
99
|
+
or (
|
|
100
|
+
act.sales_id != p_agent_sales_id -- Not mine
|
|
101
|
+
and (owner.allow_remote_processing is true or owner.allow_remote_processing is null) -- Owner allows stealing (default true)
|
|
102
|
+
and act.created_at < now() - ((coalesce(owner.stale_threshold_minutes, 15) || ' minutes')::interval)
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
order by
|
|
106
|
+
(act.sales_id = p_agent_sales_id) desc, -- My tasks first
|
|
107
|
+
(act.sales_id is null) desc, -- Global tasks second
|
|
108
|
+
act.created_at asc -- Oldest stale tasks last
|
|
109
|
+
limit 1
|
|
110
|
+
for update skip locked
|
|
111
|
+
)
|
|
112
|
+
and (a.sales_id = s_owner.id or a.sales_id is null) -- Join condition
|
|
113
|
+
returning a.id, a.raw_data, a.type, (a.sales_id is null) as is_global;
|
|
114
|
+
end;
|
|
115
|
+
$$;
|
|
116
|
+
|
|
117
|
+
-- 7. Trigger: Link Ingestion Providers -> Sales ID on Insert
|
|
118
|
+
-- If we insert an activity with a provider_id but no sales_id, try to auto-assign it based on the provider config.
|
|
119
|
+
create or replace function auto_assign_activity_owner()
|
|
120
|
+
returns trigger
|
|
121
|
+
language plpgsql
|
|
122
|
+
as $$
|
|
123
|
+
begin
|
|
124
|
+
if new.sales_id is null and new.provider_id is not null then
|
|
125
|
+
select sales_id into new.sales_id
|
|
126
|
+
from ingestion_providers
|
|
127
|
+
where id = new.provider_id;
|
|
128
|
+
end if;
|
|
129
|
+
return new;
|
|
130
|
+
end;
|
|
131
|
+
$$;
|
|
132
|
+
|
|
133
|
+
create trigger "before_insert_activity_assign_owner"
|
|
134
|
+
before insert on "public"."activities"
|
|
135
|
+
for each row execute function auto_assign_activity_owner();
|
|
136
|
+
|
|
137
|
+
-- 8. Enable pg_cron (if available, this might fail on some Supabase tiers so we wrap it)
|
|
138
|
+
do $$
|
|
139
|
+
begin
|
|
140
|
+
create extension if not exists pg_cron;
|
|
141
|
+
exception when others then
|
|
142
|
+
raise notice 'pg_cron extension could not be enabled - skipping cron setup';
|
|
143
|
+
end
|
|
144
|
+
$$;
|
|
145
|
+
|
|
146
|
+
-- 9. RLS Policies
|
|
147
|
+
-- Activities: Authenticated users can read all (for Team view). Only Owner or Global can be updated (unless claiming).
|
|
148
|
+
create policy "Enable read access for authenticated users" on "public"."activities" for select to authenticated using (true);
|
|
149
|
+
create policy "Enable insert for authenticated users" on "public"."activities" for insert to authenticated with check (true);
|
|
150
|
+
create policy "Enable update for authenticated users" on "public"."activities" for update to authenticated using (true);
|
|
151
|
+
|
|
152
|
+
-- Ingestion Providers: Admins only? For now, authenticated read.
|
|
153
|
+
create policy "Enable read access for authenticated users" on "public"."ingestion_providers" for select to authenticated using (true);
|
|
154
|
+
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
-- Migration: Add Contact Matching for Activities
|
|
2
|
+
-- Automatically links activities to contacts based on email or phone number
|
|
3
|
+
|
|
4
|
+
-- Function: Normalize phone number to E.164 format for matching
|
|
5
|
+
-- Strips formatting and ensures consistent format
|
|
6
|
+
CREATE OR REPLACE FUNCTION normalize_phone(phone_input text)
|
|
7
|
+
RETURNS text
|
|
8
|
+
LANGUAGE plpgsql
|
|
9
|
+
IMMUTABLE
|
|
10
|
+
AS $$
|
|
11
|
+
BEGIN
|
|
12
|
+
-- Remove all non-digit characters except leading +
|
|
13
|
+
RETURN regexp_replace(phone_input, '[^+0-9]', '', 'g');
|
|
14
|
+
END;
|
|
15
|
+
$$;
|
|
16
|
+
|
|
17
|
+
-- Function: Auto-link activity to contact based on email or phone
|
|
18
|
+
-- Priority: Email match > Phone match (E.164) > Fuzzy phone match (last 10 digits)
|
|
19
|
+
CREATE OR REPLACE FUNCTION auto_link_contact()
|
|
20
|
+
RETURNS TRIGGER
|
|
21
|
+
LANGUAGE plpgsql
|
|
22
|
+
AS $$
|
|
23
|
+
DECLARE
|
|
24
|
+
matched_contact_id bigint;
|
|
25
|
+
BEGIN
|
|
26
|
+
-- Only attempt matching if contact_id is not already set
|
|
27
|
+
IF NEW.contact_id IS NOT NULL THEN
|
|
28
|
+
RETURN NEW;
|
|
29
|
+
END IF;
|
|
30
|
+
|
|
31
|
+
-- Strategy 1: Try exact email match
|
|
32
|
+
-- Note: email is stored as email_jsonb (array of email objects)
|
|
33
|
+
IF NEW.metadata ? 'from' AND NEW.metadata->>'from' LIKE '%@%' THEN
|
|
34
|
+
SELECT id INTO matched_contact_id
|
|
35
|
+
FROM contacts
|
|
36
|
+
WHERE email_jsonb @> jsonb_build_array(jsonb_build_object('email', NEW.metadata->>'from'))
|
|
37
|
+
LIMIT 1;
|
|
38
|
+
|
|
39
|
+
IF matched_contact_id IS NOT NULL THEN
|
|
40
|
+
NEW.contact_id := matched_contact_id;
|
|
41
|
+
RETURN NEW;
|
|
42
|
+
END IF;
|
|
43
|
+
END IF;
|
|
44
|
+
|
|
45
|
+
-- Strategy 2: Try exact phone match (E.164 normalized)
|
|
46
|
+
-- Note: phones are stored as phone_jsonb (array of phone objects)
|
|
47
|
+
IF NEW.metadata ? 'from' AND NEW.metadata->>'from' LIKE '+%' THEN
|
|
48
|
+
SELECT id INTO matched_contact_id
|
|
49
|
+
FROM contacts
|
|
50
|
+
WHERE EXISTS (
|
|
51
|
+
SELECT 1 FROM jsonb_array_elements(phone_jsonb) AS phone
|
|
52
|
+
WHERE normalize_phone(phone->>'number') = normalize_phone(NEW.metadata->>'from')
|
|
53
|
+
)
|
|
54
|
+
LIMIT 1;
|
|
55
|
+
|
|
56
|
+
IF matched_contact_id IS NOT NULL THEN
|
|
57
|
+
NEW.contact_id := matched_contact_id;
|
|
58
|
+
RETURN NEW;
|
|
59
|
+
END IF;
|
|
60
|
+
END IF;
|
|
61
|
+
|
|
62
|
+
-- Strategy 3: Try fuzzy phone match (last 10 digits for US numbers)
|
|
63
|
+
IF NEW.metadata ? 'from' AND length(regexp_replace(NEW.metadata->>'from', '[^0-9]', '', 'g')) >= 10 THEN
|
|
64
|
+
SELECT id INTO matched_contact_id
|
|
65
|
+
FROM contacts
|
|
66
|
+
WHERE EXISTS (
|
|
67
|
+
SELECT 1 FROM jsonb_array_elements(phone_jsonb) AS phone
|
|
68
|
+
WHERE right(regexp_replace(phone->>'number', '[^0-9]', '', 'g'), 10) =
|
|
69
|
+
right(regexp_replace(NEW.metadata->>'from', '[^0-9]', '', 'g'), 10)
|
|
70
|
+
)
|
|
71
|
+
LIMIT 1;
|
|
72
|
+
|
|
73
|
+
IF matched_contact_id IS NOT NULL THEN
|
|
74
|
+
NEW.contact_id := matched_contact_id;
|
|
75
|
+
RETURN NEW;
|
|
76
|
+
END IF;
|
|
77
|
+
END IF;
|
|
78
|
+
|
|
79
|
+
-- No match found - activity will be created as "orphan" with contact_id = NULL
|
|
80
|
+
RETURN NEW;
|
|
81
|
+
END;
|
|
82
|
+
$$;
|
|
83
|
+
|
|
84
|
+
-- Trigger: Auto-link contacts before inserting activities
|
|
85
|
+
CREATE TRIGGER before_insert_activity_link_contact
|
|
86
|
+
BEFORE INSERT ON "public"."activities"
|
|
87
|
+
FOR EACH ROW
|
|
88
|
+
EXECUTE FUNCTION auto_link_contact();
|
|
89
|
+
|
|
90
|
+
-- Comment for documentation
|
|
91
|
+
COMMENT ON FUNCTION auto_link_contact() IS
|
|
92
|
+
'Automatically links activities to contacts based on email or phone number in metadata.from field.
|
|
93
|
+
Priority: Email match > Phone E.164 match > Fuzzy phone match (last 10 digits).
|
|
94
|
+
Activities without matches become orphans (contact_id = NULL).';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
-- Migration: Fix RLS policies for ingestion_providers table
|
|
2
|
+
-- Add missing INSERT, UPDATE, DELETE policies for authenticated users
|
|
3
|
+
|
|
4
|
+
-- Allow authenticated users to create ingestion channels
|
|
5
|
+
create policy "Enable insert for authenticated users"
|
|
6
|
+
on "public"."ingestion_providers"
|
|
7
|
+
for insert
|
|
8
|
+
to authenticated
|
|
9
|
+
with check (true);
|
|
10
|
+
|
|
11
|
+
-- Allow authenticated users to update their ingestion channels
|
|
12
|
+
create policy "Enable update for authenticated users"
|
|
13
|
+
on "public"."ingestion_providers"
|
|
14
|
+
for update
|
|
15
|
+
to authenticated
|
|
16
|
+
using (true);
|
|
17
|
+
|
|
18
|
+
-- Allow authenticated users to delete their ingestion channels
|
|
19
|
+
create policy "Enable delete for authenticated users"
|
|
20
|
+
on "public"."ingestion_providers"
|
|
21
|
+
for delete
|
|
22
|
+
to authenticated
|
|
23
|
+
using (true);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
-- Migration: Fix contact matching to work with email_jsonb and phone_jsonb schema
|
|
2
|
+
|
|
3
|
+
-- Drop and recreate the auto_link_contact function with correct JSONB queries
|
|
4
|
+
CREATE OR REPLACE FUNCTION auto_link_contact()
|
|
5
|
+
RETURNS TRIGGER
|
|
6
|
+
LANGUAGE plpgsql
|
|
7
|
+
AS $$
|
|
8
|
+
DECLARE
|
|
9
|
+
matched_contact_id bigint;
|
|
10
|
+
BEGIN
|
|
11
|
+
-- Only attempt matching if contact_id is not already set
|
|
12
|
+
IF NEW.contact_id IS NOT NULL THEN
|
|
13
|
+
RETURN NEW;
|
|
14
|
+
END IF;
|
|
15
|
+
|
|
16
|
+
-- Strategy 1: Try exact email match
|
|
17
|
+
-- Note: email is stored as email_jsonb (array of email objects)
|
|
18
|
+
IF NEW.metadata ? 'from' AND NEW.metadata->>'from' LIKE '%@%' THEN
|
|
19
|
+
SELECT id INTO matched_contact_id
|
|
20
|
+
FROM contacts
|
|
21
|
+
WHERE email_jsonb @> jsonb_build_array(jsonb_build_object('email', NEW.metadata->>'from'))
|
|
22
|
+
LIMIT 1;
|
|
23
|
+
|
|
24
|
+
IF matched_contact_id IS NOT NULL THEN
|
|
25
|
+
NEW.contact_id := matched_contact_id;
|
|
26
|
+
RETURN NEW;
|
|
27
|
+
END IF;
|
|
28
|
+
END IF;
|
|
29
|
+
|
|
30
|
+
-- Strategy 2: Try exact phone match (E.164 normalized)
|
|
31
|
+
-- Note: phones are stored as phone_jsonb (array of phone objects)
|
|
32
|
+
IF NEW.metadata ? 'from' AND NEW.metadata->>'from' LIKE '+%' THEN
|
|
33
|
+
SELECT id INTO matched_contact_id
|
|
34
|
+
FROM contacts
|
|
35
|
+
WHERE EXISTS (
|
|
36
|
+
SELECT 1 FROM jsonb_array_elements(phone_jsonb) AS phone
|
|
37
|
+
WHERE normalize_phone(phone->>'number') = normalize_phone(NEW.metadata->>'from')
|
|
38
|
+
)
|
|
39
|
+
LIMIT 1;
|
|
40
|
+
|
|
41
|
+
IF matched_contact_id IS NOT NULL THEN
|
|
42
|
+
NEW.contact_id := matched_contact_id;
|
|
43
|
+
RETURN NEW;
|
|
44
|
+
END IF;
|
|
45
|
+
END IF;
|
|
46
|
+
|
|
47
|
+
-- Strategy 3: Try fuzzy phone match (last 10 digits for US numbers)
|
|
48
|
+
IF NEW.metadata ? 'from' AND length(regexp_replace(NEW.metadata->>'from', '[^0-9]', '', 'g')) >= 10 THEN
|
|
49
|
+
SELECT id INTO matched_contact_id
|
|
50
|
+
FROM contacts
|
|
51
|
+
WHERE EXISTS (
|
|
52
|
+
SELECT 1 FROM jsonb_array_elements(phone_jsonb) AS phone
|
|
53
|
+
WHERE right(regexp_replace(phone->>'number', '[^0-9]', '', 'g'), 10) =
|
|
54
|
+
right(regexp_replace(NEW.metadata->>'from', '[^0-9]', '', 'g'), 10)
|
|
55
|
+
)
|
|
56
|
+
LIMIT 1;
|
|
57
|
+
|
|
58
|
+
IF matched_contact_id IS NOT NULL THEN
|
|
59
|
+
NEW.contact_id := matched_contact_id;
|
|
60
|
+
RETURN NEW;
|
|
61
|
+
END IF;
|
|
62
|
+
END IF;
|
|
63
|
+
|
|
64
|
+
-- No match found - activity will be created as "orphan" with contact_id = NULL
|
|
65
|
+
RETURN NEW;
|
|
66
|
+
END;
|
|
67
|
+
$$;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
-- Migration: Fix email matching to be more robust with jsonb arrays
|
|
2
|
+
|
|
3
|
+
CREATE OR REPLACE FUNCTION auto_link_contact()
|
|
4
|
+
RETURNS TRIGGER
|
|
5
|
+
LANGUAGE plpgsql
|
|
6
|
+
AS $$
|
|
7
|
+
DECLARE
|
|
8
|
+
matched_contact_id bigint;
|
|
9
|
+
BEGIN
|
|
10
|
+
-- Only attempt matching if contact_id is not already set
|
|
11
|
+
IF NEW.contact_id IS NOT NULL THEN
|
|
12
|
+
RETURN NEW;
|
|
13
|
+
END IF;
|
|
14
|
+
|
|
15
|
+
-- Strategy 1: Try exact email match
|
|
16
|
+
-- Note: email is stored as email_jsonb (array of email objects)
|
|
17
|
+
-- Use jsonb_array_elements to check each email in the array
|
|
18
|
+
IF NEW.metadata ? 'from' AND NEW.metadata->>'from' LIKE '%@%' THEN
|
|
19
|
+
SELECT c.id INTO matched_contact_id
|
|
20
|
+
FROM contacts c,
|
|
21
|
+
jsonb_array_elements(c.email_jsonb) AS email
|
|
22
|
+
WHERE lower(email->>'email') = lower(NEW.metadata->>'from')
|
|
23
|
+
LIMIT 1;
|
|
24
|
+
|
|
25
|
+
IF matched_contact_id IS NOT NULL THEN
|
|
26
|
+
NEW.contact_id := matched_contact_id;
|
|
27
|
+
RETURN NEW;
|
|
28
|
+
END IF;
|
|
29
|
+
END IF;
|
|
30
|
+
|
|
31
|
+
-- Strategy 2: Try exact phone match (E.164 normalized)
|
|
32
|
+
-- Note: phones are stored as phone_jsonb (array of phone objects)
|
|
33
|
+
IF NEW.metadata ? 'from' AND NEW.metadata->>'from' LIKE '+%' THEN
|
|
34
|
+
SELECT c.id INTO matched_contact_id
|
|
35
|
+
FROM contacts c,
|
|
36
|
+
jsonb_array_elements(c.phone_jsonb) AS phone
|
|
37
|
+
WHERE normalize_phone(phone->>'number') = normalize_phone(NEW.metadata->>'from')
|
|
38
|
+
LIMIT 1;
|
|
39
|
+
|
|
40
|
+
IF matched_contact_id IS NOT NULL THEN
|
|
41
|
+
NEW.contact_id := matched_contact_id;
|
|
42
|
+
RETURN NEW;
|
|
43
|
+
END IF;
|
|
44
|
+
END IF;
|
|
45
|
+
|
|
46
|
+
-- Strategy 3: Try fuzzy phone match (last 10 digits for US numbers)
|
|
47
|
+
IF NEW.metadata ? 'from' AND length(regexp_replace(NEW.metadata->>'from', '[^0-9]', '', 'g')) >= 10 THEN
|
|
48
|
+
SELECT c.id INTO matched_contact_id
|
|
49
|
+
FROM contacts c,
|
|
50
|
+
jsonb_array_elements(c.phone_jsonb) AS phone
|
|
51
|
+
WHERE right(regexp_replace(phone->>'number', '[^0-9]', '', 'g'), 10) =
|
|
52
|
+
right(regexp_replace(NEW.metadata->>'from', '[^0-9]', '', 'g'), 10)
|
|
53
|
+
LIMIT 1;
|
|
54
|
+
|
|
55
|
+
IF matched_contact_id IS NOT NULL THEN
|
|
56
|
+
NEW.contact_id := matched_contact_id;
|
|
57
|
+
RETURN NEW;
|
|
58
|
+
END IF;
|
|
59
|
+
END IF;
|
|
60
|
+
|
|
61
|
+
-- No match found - activity will be created as "orphan" with contact_id = NULL
|
|
62
|
+
RETURN NEW;
|
|
63
|
+
END;
|
|
64
|
+
$$;
|
|
65
|
+
|
|
66
|
+
COMMENT ON FUNCTION auto_link_contact() IS
|
|
67
|
+
'Automatically links activities to contacts based on email or phone number in metadata.from field.
|
|
68
|
+
Uses case-insensitive email matching and handles jsonb array structures correctly.
|
|
69
|
+
Priority: Email match > Phone E.164 match > Fuzzy phone match (last 10 digits).
|
|
70
|
+
Activities without matches become orphans (contact_id = NULL).';
|