-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathschema.sql
More file actions
589 lines (517 loc) · 18.4 KB
/
schema.sql
File metadata and controls
589 lines (517 loc) · 18.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
-- Enable required extensions
-- NOTE: Provision application login credentials via the Supabase Dashboard (Auth -> Users);
-- this schema intentionally avoids seeding auth.users directly to match dashboard flows.
create extension if not exists "pgcrypto";
-- Message templates stored per project
create table if not exists public.message_templates (
id uuid primary key default gen_random_uuid(),
name text not null unique,
body text not null,
locale text not null default 'en',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- Contact directory with searchable metadata and delivery status
create table if not exists public.contacts (
id uuid primary key default gen_random_uuid(),
full_name text not null,
phone text,
email text,
company text,
is_flagged boolean not null default false,
flagged_reason text,
flagged_at timestamptz,
flagged_by uuid,
last_sent_at timestamptz,
created_by uuid,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
status text generated always as (
case when last_sent_at is not null then 'green' else 'red' end
) stored,
search_vector tsvector generated always as (
setweight(to_tsvector('simple', coalesce(full_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(phone, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(email, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(company, '')), 'B')
) stored,
constraint contacts_valid_phone check (phone is null or char_length(trim(phone)) >= 6),
constraint contacts_phone_or_email check (phone is not null or email is not null)
);
drop index if exists public.contacts_phone_key;
drop index if exists public.contacts_email_key;
create unique index if not exists contacts_phone_key on public.contacts (phone);
create unique index if not exists contacts_email_key on public.contacts (email);
create index if not exists contacts_search_vector_idx on public.contacts using gin (search_vector);
create index if not exists contacts_flagged_idx on public.contacts (is_flagged) where is_flagged;
create table if not exists public.contact_metrics (
id integer primary key check (id = 1),
total_contacts integer not null default 0,
updated_at timestamptz not null default now()
);
create table if not exists public.send_metrics (
id uuid primary key default gen_random_uuid(),
sent_at timestamptz not null default now(),
recipient_count integer not null check (recipient_count >= 0),
mode text not null default 'web',
message_body text,
template_id uuid references public.message_templates(id) on delete set null,
created_by uuid,
constraint send_metrics_body_or_template check (message_body is not null or template_id is not null),
constraint send_metrics_mode check (mode in ('web', 'api'))
);
-- Per-contact delivery log linking metrics to contacts
create table if not exists public.contact_sends (
id uuid primary key default gen_random_uuid(),
contact_id uuid not null references public.contacts(id) on delete cascade,
send_metric_id uuid references public.send_metrics(id) on delete cascade,
sent_at timestamptz not null default now(),
created_by uuid,
created_at timestamptz not null default now()
);
create index if not exists contact_sends_contact_sent_idx on public.contact_sends (contact_id, sent_at desc);
create index if not exists contact_sends_metric_idx on public.contact_sends (send_metric_id);
-- Capture failed deliveries for reporting and rollups
create table if not exists public.send_failures (
id uuid primary key default gen_random_uuid(),
contact_id uuid references public.contacts(id) on delete set null,
send_metric_id uuid references public.send_metrics(id) on delete cascade,
failure_reason text not null,
error_detail jsonb,
occurred_at timestamptz not null default now(),
created_by uuid,
created_at timestamptz not null default now()
);
create index if not exists send_failures_contact_idx on public.send_failures (contact_id, occurred_at desc);
create index if not exists send_failures_metric_idx on public.send_failures (send_metric_id);
-- Daily rollups for throughput analytics
create table if not exists public.send_daily_stats (
stat_date date not null,
mode text not null,
success_count integer not null default 0,
failure_count integer not null default 0,
failure_reasons jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
primary key (stat_date, mode)
);
create table if not exists public.service_heartbeats (
id integer primary key check (id = 1),
label text not null default 'massapp',
last_ping timestamptz not null default now(),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- Automatically set updated_at on templates
create or replace function public.handle_updated_at()
returns trigger as $$
begin
new.updated_at = now();
return new;
end;
$$ language plpgsql;
create trigger set_message_templates_updated_at
before update on public.message_templates
for each row execute procedure public.handle_updated_at();
create trigger set_contacts_updated_at
before update on public.contacts
for each row execute procedure public.handle_updated_at();
create trigger set_daily_stats_updated_at
before update on public.send_daily_stats
for each row execute procedure public.handle_updated_at();
create trigger set_service_heartbeats_updated_at
before update on public.service_heartbeats
for each row execute procedure public.handle_updated_at();
-- Row Level Security
alter table public.message_templates enable row level security;
alter table public.send_metrics enable row level security;
alter table public.contacts enable row level security;
alter table public.contact_sends enable row level security;
alter table public.send_failures enable row level security;
alter table public.send_daily_stats enable row level security;
alter table public.service_heartbeats enable row level security;
alter table public.contact_metrics enable row level security;
-- Ensure created_by is recorded
create or replace function public.assign_created_by()
returns trigger as $$
begin
if new.created_by is null then
new.created_by = auth.uid();
end if;
return new;
end;
$$ language plpgsql security definer;
create or replace function public.bump_contact_metrics(delta integer)
returns void
language plpgsql
security definer
set search_path = public
as $$
begin
insert into public.contact_metrics (id, total_contacts)
values (1, greatest(delta, 0))
on conflict (id) do update
set total_contacts = greatest(contact_metrics.total_contacts + delta, 0),
updated_at = now();
end;
$$;
create or replace function public.handle_contact_metrics_insert()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
perform public.bump_contact_metrics(1);
return new;
end;
$$;
create or replace function public.handle_contact_metrics_delete()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
perform public.bump_contact_metrics(-1);
return old;
end;
$$;
create or replace function public.refresh_contact_metrics()
returns integer
language plpgsql
security definer
set search_path = public
as $$
declare
total integer;
begin
select count(*) into total from public.contacts;
insert into public.contact_metrics (id, total_contacts)
values (1, total)
on conflict (id) do update
set total_contacts = total,
updated_at = now();
return total;
end;
$$;
create trigger set_send_metrics_created_by
before insert on public.send_metrics
for each row execute procedure public.assign_created_by();
create trigger set_contacts_created_by
before insert on public.contacts
for each row execute procedure public.assign_created_by();
create trigger increment_contact_metrics
after insert on public.contacts
for each row execute procedure public.handle_contact_metrics_insert();
create trigger decrement_contact_metrics
after delete on public.contacts
for each row execute procedure public.handle_contact_metrics_delete();
create trigger set_contact_sends_created_by
before insert on public.contact_sends
for each row execute procedure public.assign_created_by();
create trigger set_send_failures_created_by
before insert on public.send_failures
for each row execute procedure public.assign_created_by();
-- Maintain last_sent_at for contacts when new deliveries are recorded
create or replace function public.touch_contact_last_sent()
returns trigger as $$
begin
update public.contacts as c
set last_sent_at = greatest(coalesce(c.last_sent_at, new.sent_at), new.sent_at)
where c.id = new.contact_id;
return new;
end;
$$ language plpgsql security definer;
create trigger set_contact_last_sent
after insert on public.contact_sends
for each row execute procedure public.touch_contact_last_sent();
-- Maintain daily rollups for successes and failures
create or replace function public.apply_daily_stats(stat_date date, mode text, success_inc integer, failure_inc integer, failure_reason text default null)
returns void
language plpgsql
security definer
as $$
declare
safe_success integer := greatest(coalesce(success_inc, 0), 0);
safe_failure integer := greatest(coalesce(failure_inc, 0), 0);
begin
insert into public.send_daily_stats (stat_date, mode, success_count, failure_count, failure_reasons)
values (
stat_date,
mode,
safe_success,
safe_failure,
case
when failure_reason is null or safe_failure = 0 then '{}'::jsonb
else jsonb_build_object(failure_reason, safe_failure)
end
)
on conflict (stat_date, mode)
do update
set success_count = public.send_daily_stats.success_count + safe_success,
failure_count = public.send_daily_stats.failure_count + safe_failure,
failure_reasons = case
when failure_reason is null or safe_failure = 0 then public.send_daily_stats.failure_reasons
else jsonb_set(
coalesce(public.send_daily_stats.failure_reasons, '{}'::jsonb),
array[failure_reason],
to_jsonb(coalesce((public.send_daily_stats.failure_reasons ->> failure_reason)::integer, 0) + safe_failure),
true)
end,
updated_at = now();
end;
$$;
create or replace function public.bump_daily_stats_for_success()
returns trigger
language plpgsql
security definer
as $$
declare
metric public.send_metrics%rowtype;
target_date date;
begin
if new.send_metric_id is null then
return new;
end if;
select * into metric from public.send_metrics where id = new.send_metric_id;
if not found then
return new;
end if;
target_date := date_trunc('day', coalesce(new.sent_at, metric.sent_at))::date;
perform public.apply_daily_stats(target_date, metric.mode, 1, 0, null);
return new;
end;
$$;
create trigger accumulate_daily_success
after insert on public.contact_sends
for each row execute procedure public.bump_daily_stats_for_success();
create or replace function public.bump_daily_stats_for_failure()
returns trigger
language plpgsql
security definer
as $$
declare
metric public.send_metrics%rowtype;
target_date date;
failure_mode text := 'web';
begin
if new.send_metric_id is not null then
select * into metric from public.send_metrics where id = new.send_metric_id;
if found then
failure_mode := metric.mode;
target_date := date_trunc('day', coalesce(new.occurred_at, metric.sent_at))::date;
end if;
end if;
if target_date is null then
target_date := date_trunc('day', coalesce(new.occurred_at, now()))::date;
end if;
perform public.apply_daily_stats(target_date, failure_mode, 0, 1, new.failure_reason);
return new;
end;
$$;
create trigger accumulate_daily_failure
after insert on public.send_failures
for each row execute procedure public.bump_daily_stats_for_failure();
-- Policies for authenticated users
create policy "Authenticated users can manage templates"
on public.message_templates
for all
using (auth.role() = 'authenticated')
with check (auth.role() = 'authenticated');
create policy "Authenticated users can read metrics"
on public.send_metrics
for select
using (auth.role() = 'authenticated');
create policy "Authenticated users can insert metrics"
on public.send_metrics
for insert
with check (auth.role() = 'authenticated');
create policy "Authenticated users can manage contacts"
on public.contacts
for all
using (auth.role() = 'authenticated')
with check (auth.role() = 'authenticated');
create policy "Authenticated users can manage contact sends"
on public.contact_sends
for all
using (auth.role() = 'authenticated')
with check (auth.role() = 'authenticated');
create policy "Authenticated users can manage failures"
on public.send_failures
for all
using (auth.role() = 'authenticated')
with check (auth.role() = 'authenticated');
create policy "Authenticated users can read contact metrics"
on public.contact_metrics
for select
using (auth.role() = 'authenticated');
create policy "Block direct writes to contact metrics"
on public.contact_metrics
for all
using (false)
with check (false);
create policy "Authenticated users can read daily stats"
on public.send_daily_stats
for select
using (auth.role() = 'authenticated');
create policy "Service roles can maintain daily stats"
on public.send_daily_stats
for all
using (auth.role() in ('authenticated', 'service_role'))
with check (auth.role() in ('authenticated', 'service_role'));
create policy "Allow read access to service heartbeat"
on public.service_heartbeats
for select
using (auth.role() in ('anon', 'authenticated', 'service_role'));
create policy "Block direct writes to service heartbeat"
on public.service_heartbeats
for all
using (false)
with check (false);
-- Duplicate detection helpers
create or replace view public.contact_merge_candidates as
select
lower(trim(full_name)) as normalized_name,
array_agg(id order by created_at) as contact_ids,
count(*) as contact_count,
array_remove(array_agg(distinct phone), null) as phones,
array_remove(array_agg(distinct email), null) as emails
from public.contacts
group by lower(trim(full_name))
having count(*) > 1;
create or replace function public.merge_contacts(primary_id uuid, secondary_id uuid)
returns uuid
language plpgsql
security definer
as $$
declare
primary_rec public.contacts%rowtype;
secondary_rec public.contacts%rowtype;
best_name text;
best_phone text;
best_email text;
best_company text;
best_last_sent timestamptz;
begin
if primary_id = secondary_id then
return primary_id;
end if;
select * into primary_rec from public.contacts where id = primary_id for update;
if not found then
raise exception 'Primary contact % not found', primary_id;
end if;
select * into secondary_rec from public.contacts where id = secondary_id for update;
if not found then
raise exception 'Secondary contact % not found', secondary_id;
end if;
best_name := case
when coalesce(length(primary_rec.full_name), 0) >= coalesce(length(secondary_rec.full_name), 0) then primary_rec.full_name
else secondary_rec.full_name
end;
best_phone := case
when primary_rec.phone is null then secondary_rec.phone
when secondary_rec.phone is null then primary_rec.phone
when length(primary_rec.phone) >= length(secondary_rec.phone) then primary_rec.phone
else secondary_rec.phone
end;
best_email := case
when primary_rec.email is null then secondary_rec.email
when secondary_rec.email is null then primary_rec.email
when length(primary_rec.email) >= length(secondary_rec.email) then primary_rec.email
else secondary_rec.email
end;
best_company := case
when coalesce(length(primary_rec.company), 0) >= coalesce(length(secondary_rec.company), 0) then primary_rec.company
else secondary_rec.company
end;
best_last_sent := greatest(coalesce(primary_rec.last_sent_at, '-infinity'::timestamptz), coalesce(secondary_rec.last_sent_at, '-infinity'::timestamptz));
update public.contacts
set full_name = best_name,
phone = best_phone,
email = best_email,
company = best_company,
last_sent_at = case
when best_last_sent = '-infinity'::timestamptz then null
else best_last_sent
end,
updated_at = now()
where id = primary_id;
update public.contact_sends
set contact_id = primary_id
where contact_id = secondary_id;
update public.send_failures
set contact_id = primary_id
where contact_id = secondary_id;
delete from public.contacts where id = secondary_id;
return primary_id;
end;
$$;
grant execute on function public.merge_contacts(uuid, uuid) to authenticated;
grant execute on function public.refresh_contact_metrics() to service_role;
create or replace function public.touch_service_heartbeat()
returns timestamptz
language plpgsql
security definer
set search_path = public
as $$
declare
current_ping timestamptz;
begin
insert into public.service_heartbeats (id, label, last_ping)
values (1, 'massapp', now())
on conflict (id) do update
set last_ping = now();
select last_ping into current_ping from public.service_heartbeats where id = 1;
return current_ping;
end;
$$;
grant execute on function public.touch_service_heartbeat() to anon, authenticated;
-- Password gate for the lightweight app login
create table if not exists public.app_password_guard (
id integer primary key check (id = 1),
password_hash text not null,
updated_at timestamptz not null default now()
);
alter table public.app_password_guard enable row level security;
create policy "No direct access to app password"
on public.app_password_guard
for all
using (false)
with check (false);
create or replace function public.set_app_password(new_password text)
returns void
language plpgsql
security definer
set search_path = public
as $$
declare
hashed text;
begin
if new_password is null or length(new_password) < 12 then
raise exception 'Password must be at least 12 characters long';
end if;
hashed := crypt(new_password, gen_salt('bf'));
insert into public.app_password_guard (id, password_hash)
values (1, hashed)
on conflict (id) do update set password_hash = excluded.password_hash, updated_at = now();
end;
$$;
create or replace function public.verify_app_password(candidate text)
returns boolean
language plpgsql
security definer
stable
set search_path = public
as $$
declare
stored text;
begin
select password_hash into stored from public.app_password_guard where id = 1;
if stored is null then
return false;
end if;
return stored = crypt(candidate, stored);
end;
$$;
grant execute on function public.verify_app_password(text) to anon, authenticated;