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.
Files changed (55) hide show
  1. package/bin/realtimex-crm.js +56 -32
  2. package/dist/assets/{DealList-B3alafQv.js → DealList-DbwJCRGl.js} +2 -2
  3. package/dist/assets/{DealList-B3alafQv.js.map → DealList-DbwJCRGl.js.map} +1 -1
  4. package/dist/assets/index-C__S90Gb.css +1 -0
  5. package/dist/assets/index-mE-upBfc.js +166 -0
  6. package/dist/assets/index-mE-upBfc.js.map +1 -0
  7. package/dist/index.html +1 -1
  8. package/dist/stats.html +1 -1
  9. package/package.json +3 -1
  10. package/src/components/atomic-crm/activities/ActivitiesPage.tsx +16 -0
  11. package/src/components/atomic-crm/activities/ActivityFeed.tsx +212 -0
  12. package/src/components/atomic-crm/activities/FileUpload.tsx +359 -0
  13. package/src/components/atomic-crm/contacts/ContactShow.tsx +28 -10
  14. package/src/components/atomic-crm/integrations/ApiKeysTab.tsx +184 -0
  15. package/src/components/atomic-crm/integrations/CreateApiKeyDialog.tsx +217 -0
  16. package/src/components/atomic-crm/integrations/CreateChannelDialog.tsx +139 -0
  17. package/src/components/atomic-crm/integrations/IngestionChannelsTab.tsx +188 -0
  18. package/src/components/atomic-crm/integrations/IntegrationsPage.tsx +46 -0
  19. package/src/components/atomic-crm/integrations/WebhooksTab.tsx +402 -0
  20. package/src/components/atomic-crm/layout/Header.tsx +14 -1
  21. package/src/components/atomic-crm/root/CRM.tsx +2 -0
  22. package/src/components/ui/alert-dialog.tsx +155 -0
  23. package/src/lib/api-key-utils.ts +22 -0
  24. package/supabase/fix_webhook_hardcoded.sql +34 -0
  25. package/supabase/functions/_shared/apiKeyAuth.ts +171 -0
  26. package/supabase/functions/_shared/ingestionGuard.ts +128 -0
  27. package/supabase/functions/_shared/utils.ts +1 -1
  28. package/supabase/functions/_shared/webhookSignature.ts +23 -0
  29. package/supabase/functions/api-v1-activities/index.ts +137 -0
  30. package/supabase/functions/api-v1-companies/index.ts +166 -0
  31. package/supabase/functions/api-v1-contacts/index.ts +171 -0
  32. package/supabase/functions/api-v1-deals/index.ts +166 -0
  33. package/supabase/functions/ingest-activity/.well-known/supabase/config.toml +4 -0
  34. package/supabase/functions/ingest-activity/index.ts +261 -0
  35. package/supabase/functions/webhook-dispatcher/index.ts +133 -0
  36. package/supabase/migrations/20251219120000_api_integrations.sql +133 -0
  37. package/supabase/migrations/20251219120100_webhook_triggers.sql +176 -0
  38. package/supabase/migrations/20251219120200_webhook_cron.sql +26 -0
  39. package/supabase/migrations/20251220120000_realtime_ingestion.sql +154 -0
  40. package/supabase/migrations/20251221000000_contact_matching.sql +94 -0
  41. package/supabase/migrations/20251221000001_fix_ingestion_providers_rls.sql +23 -0
  42. package/supabase/migrations/20251221000002_fix_contact_matching_jsonb.sql +67 -0
  43. package/supabase/migrations/20251221000003_fix_email_matching.sql +70 -0
  44. package/supabase/migrations/20251221000004_time_based_work_stealing.sql +99 -0
  45. package/supabase/migrations/20251221000005_realtime_functions.sql +73 -0
  46. package/supabase/migrations/20251221000006_enable_pg_net.sql +3 -0
  47. package/supabase/migrations/20251222075019_enable_extensions_and_configure_cron.sql +86 -0
  48. package/supabase/migrations/20251222094036_large_payload_storage.sql +213 -0
  49. package/supabase/migrations/20251222094247_large_payload_cron.sql +28 -0
  50. package/supabase/migrations/20251222220000_fix_large_payload_types.sql +72 -0
  51. package/supabase/migrations/20251223000000_enable_realtime_all_crm_tables.sql +50 -0
  52. package/supabase/migrations/20251223185638_remove_large_payload_storage.sql +54 -0
  53. package/dist/assets/index-CHk72bAf.css +0 -1
  54. package/dist/assets/index-mhAjQ1_w.js +0 -153
  55. 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).';