create-bw-app 0.22.2 → 0.23.0

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "create-bw-app",
3
3
  "private": false,
4
- "version": "0.22.2",
4
+ "version": "0.23.0",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "create-bw-app": "bin/create-bw-app.mjs",
package/src/constants.mjs CHANGED
@@ -96,6 +96,8 @@ export const MODULE_STARTER_FILES = {
96
96
  "app/api/marketing/segments/preview/route.ts",
97
97
  "app/api/marketing/segments/route.ts",
98
98
  "app/api/marketing/topics/route.ts",
99
+ "app/api/marketing/topics/[id]/route.ts",
100
+ "app/api/marketing/topics/order/route.ts",
99
101
  "app/api/marketing/unsubscribe/[token]/route.ts",
100
102
  "app/api/marketing/webhooks/resend/route.ts",
101
103
  "app/api/marketing/worker/route.ts",
@@ -171,14 +173,14 @@ export const PLATFORM_STARTER_FILES = [
171
173
  ];
172
174
 
173
175
  export const APP_DEPENDENCY_DEFAULTS = {
174
- "@brightweblabs/app-shell": "^0.9.3",
175
- "@brightweblabs/core-auth": "^0.9.1",
176
- "@brightweblabs/infra": "^0.6.0",
177
- "@brightweblabs/module-admin": "^0.8.0",
178
- "@brightweblabs/module-crm": "^0.12.0",
179
- "@brightweblabs/module-marketing": "^0.2.15",
180
- "@brightweblabs/module-orgs": "^0.3.16",
181
- "@brightweblabs/module-projects": "^0.11.0",
176
+ "@brightweblabs/app-shell": "^0.10.0",
177
+ "@brightweblabs/core-auth": "^0.9.2",
178
+ "@brightweblabs/infra": "^0.7.0",
179
+ "@brightweblabs/module-admin": "^0.8.1",
180
+ "@brightweblabs/module-crm": "^0.13.0",
181
+ "@brightweblabs/module-marketing": "^0.3.0",
182
+ "@brightweblabs/module-orgs": "^0.3.17",
183
+ "@brightweblabs/module-projects": "^0.12.0",
182
184
  "@brightweblabs/theme": "^0.7.1",
183
185
  "@brightweblabs/ui": "^1.4.1",
184
186
  "geist": "1.7.2",
package/src/generator.mjs CHANGED
@@ -942,7 +942,6 @@ export function createShellConfig(selectedModules) {
942
942
  importLines.push('import { projectsModuleRegistration } from "@brightweblabs/module-projects/registration";');
943
943
  registrationLines.push(' if (enabled.has("projects")) registrations.push(projectsModuleRegistration);');
944
944
  }
945
-
946
945
  return [
947
946
  "// MANAGED BY BRIGHTWEB — regenerated by create-bw-app update; put customizations in config/shell.overrides.ts",
948
947
  'import { LayoutDashboard, Wrench } from "lucide-react";',
@@ -1035,6 +1034,10 @@ export function createModuleToolbarControlsConfig(selectedModules) {
1035
1034
  imports.push('import { ProjectsToolbarControls } from "@brightweblabs/module-projects/ui";');
1036
1035
  entries.push(' projects: () => <ProjectsToolbarControls />,');
1037
1036
  }
1037
+ if (selectedModules.includes("marketing")) {
1038
+ imports.push('import { MarketingToolbarControls } from "@brightweblabs/module-marketing/ui";');
1039
+ entries.push(' marketing: () => <MarketingToolbarControls />,');
1040
+ }
1038
1041
 
1039
1042
  return [
1040
1043
  '"use client";',
@@ -3,15 +3,15 @@
3
3
  import { AppDashboard, type DashboardDataClient } from "@brightweblabs/app-shell";
4
4
  import { getStarterShellConfig } from "../../../config/shell";
5
5
 
6
- async function getJson(path: string) {
7
- const response = await fetch(path);
6
+ async function getJson(path: string, signal?: AbortSignal) {
7
+ const response = await fetch(path, { cache: "no-store", signal });
8
8
  return response.json();
9
9
  }
10
10
 
11
11
  const dashboardClient: DashboardDataClient = {
12
- getProjects: () => getJson("/api/dashboard/projects"),
13
- getCrm: () => getJson("/api/dashboard/crm"),
14
- getTasks: () => getJson("/api/dashboard/tasks"),
12
+ getProjects: (options) => getJson("/api/dashboard/projects", options?.signal),
13
+ getCrm: (options) => getJson("/api/dashboard/crm", options?.signal),
14
+ getTasks: (options) => getJson("/api/dashboard/tasks", options?.signal),
15
15
  };
16
16
 
17
17
  export function DashboardLiveMount() {
@@ -5,12 +5,14 @@ import { resolveShellToolbarSurface, type ShellToolbarRouteConfig, type ShellToo
5
5
  import { AdminToolbarControls } from "@brightweblabs/module-admin/ui";
6
6
  import { CrmToolbarControls } from "@brightweblabs/module-crm/ui";
7
7
  import { ProjectsToolbarControls } from "@brightweblabs/module-projects/ui";
8
+ import { MarketingToolbarControls } from "@brightweblabs/module-marketing/ui";
8
9
 
9
10
  // MANAGED BY BRIGHTWEB — regenerated when modules are added, removed, or updated.
10
11
  const toolbarControlBySurface: Partial<Record<ShellToolbarSurface, () => ReactNode>> = {
11
12
  "admin-users": () => <AdminToolbarControls />,
12
13
  crm: () => <CrmToolbarControls />,
13
14
  projects: () => <ProjectsToolbarControls />,
15
+ marketing: () => <MarketingToolbarControls />,
14
16
  };
15
17
 
16
18
  export function getModuleToolbarControls(pathname: string, toolbarRoutes: ShellToolbarRouteConfig[]) {
@@ -58,6 +58,15 @@ export function marketingSegmentsPost(request: Request) {
58
58
  export function marketingTopicsGet(request: Request) {
59
59
  return handlers.topicsGet(request);
60
60
  }
61
+ export function marketingTopicsPost(request: Request) {
62
+ return handlers.topicsPost(request);
63
+ }
64
+ export function marketingTopicsOrderPost(request: Request) {
65
+ return handlers.topicsOrderPost(request);
66
+ }
67
+ export function marketingTopicPatch(request: Request, context: IdRouteContext) {
68
+ return handlers.topicPatch(request, context);
69
+ }
61
70
  export function marketingWebhookPost(request: Request) {
62
71
  return handlers.webhookPost(request);
63
72
  }
@@ -0,0 +1,2 @@
1
+ export { marketingTopicPatch as PATCH } from "../../_handlers";
2
+ export const dynamic = "force-dynamic";
@@ -0,0 +1 @@
1
+ export { marketingTopicsOrderPost as POST } from "../../_handlers";
@@ -1,2 +1,2 @@
1
- export { marketingTopicsGet as GET } from "../_handlers";
1
+ export { marketingTopicsGet as GET, marketingTopicsPost as POST } from "../_handlers";
2
2
  export const dynamic = "force-dynamic";
@@ -0,0 +1,13 @@
1
+ -- Accelerate staff-facing user and member searches without weakening RLS.
2
+ CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA extensions;
3
+
4
+ CREATE INDEX IF NOT EXISTS profiles_email_trgm_idx
5
+ ON public.profiles USING gin (email extensions.gin_trgm_ops);
6
+ CREATE INDEX IF NOT EXISTS profiles_first_name_trgm_idx
7
+ ON public.profiles USING gin (first_name extensions.gin_trgm_ops);
8
+ CREATE INDEX IF NOT EXISTS profiles_last_name_trgm_idx
9
+ ON public.profiles USING gin (last_name extensions.gin_trgm_ops);
10
+ CREATE INDEX IF NOT EXISTS user_role_assignments_assigned_idx
11
+ ON public.user_role_assignments (assigned_at DESC);
12
+ CREATE INDEX IF NOT EXISTS user_role_assignments_role_assigned_idx
13
+ ON public.user_role_assignments (role_code, assigned_at DESC);
@@ -0,0 +1,155 @@
1
+ -- Support contains-search and the most common CRM collection filters/sorts.
2
+ CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA extensions;
3
+
4
+ CREATE INDEX IF NOT EXISTS crm_contacts_first_name_trgm_idx
5
+ ON public.crm_contacts USING gin (first_name extensions.gin_trgm_ops);
6
+ CREATE INDEX IF NOT EXISTS crm_contacts_last_name_trgm_idx
7
+ ON public.crm_contacts USING gin (last_name extensions.gin_trgm_ops);
8
+ CREATE INDEX IF NOT EXISTS crm_contacts_email_trgm_idx
9
+ ON public.crm_contacts USING gin (email extensions.gin_trgm_ops);
10
+ CREATE INDEX IF NOT EXISTS crm_contacts_phone_trgm_idx
11
+ ON public.crm_contacts USING gin (phone extensions.gin_trgm_ops);
12
+ CREATE INDEX IF NOT EXISTS crm_contacts_status_updated_idx
13
+ ON public.crm_contacts (status, updated_at DESC);
14
+ CREATE INDEX IF NOT EXISTS crm_contacts_organization_updated_idx
15
+ ON public.crm_contacts (organization_id, updated_at DESC);
16
+ CREATE INDEX IF NOT EXISTS crm_contacts_owner_updated_idx
17
+ ON public.crm_contacts (owner_id, updated_at DESC);
18
+ CREATE INDEX IF NOT EXISTS crm_contacts_source_updated_idx
19
+ ON public.crm_contacts (source, updated_at DESC);
20
+ CREATE INDEX IF NOT EXISTS crm_status_log_reason_trgm_idx
21
+ ON public.crm_status_log USING gin (reason extensions.gin_trgm_ops);
22
+ CREATE INDEX IF NOT EXISTS crm_status_log_contact_changed_idx
23
+ ON public.crm_status_log (contact_id, changed_at DESC);
24
+ CREATE INDEX IF NOT EXISTS crm_status_log_actor_changed_idx
25
+ ON public.crm_status_log (changed_by_user_id, changed_at DESC);
26
+ CREATE INDEX IF NOT EXISTS crm_status_log_status_changed_idx
27
+ ON public.crm_status_log (new_status, changed_at DESC);
28
+
29
+ CREATE OR REPLACE FUNCTION public.get_crm_contact_stats()
30
+ RETURNS TABLE (
31
+ total_count bigint,
32
+ lead_count bigint,
33
+ qualified_count bigint,
34
+ proposal_count bigint,
35
+ won_count bigint,
36
+ lost_count bigint,
37
+ qualified_last_30_days bigint,
38
+ won_last_30_days bigint,
39
+ new_last_7_days bigint,
40
+ new_last_30_days bigint,
41
+ new_last_year bigint
42
+ )
43
+ LANGUAGE sql
44
+ STABLE
45
+ SECURITY INVOKER
46
+ SET search_path = public, pg_temp
47
+ AS $$
48
+ WITH contact_counts AS (
49
+ SELECT
50
+ count(*) AS total_count,
51
+ count(*) FILTER (WHERE status = 'lead') AS lead_count,
52
+ count(*) FILTER (WHERE status = 'qualified') AS qualified_count,
53
+ count(*) FILTER (WHERE status = 'proposal') AS proposal_count,
54
+ count(*) FILTER (WHERE status = 'won') AS won_count,
55
+ count(*) FILTER (WHERE status = 'lost') AS lost_count,
56
+ count(*) FILTER (WHERE created_at >= now() - interval '7 days') AS new_last_7_days,
57
+ count(*) FILTER (WHERE created_at >= now() - interval '30 days') AS new_last_30_days,
58
+ count(*) FILTER (WHERE created_at >= now() - interval '365 days') AS new_last_year
59
+ FROM public.crm_contacts
60
+ ),
61
+ activity_counts AS (
62
+ SELECT
63
+ count(*) FILTER (WHERE new_status = 'qualified' AND changed_at >= now() - interval '30 days') AS qualified_last_30_days,
64
+ count(*) FILTER (WHERE new_status = 'won' AND changed_at >= now() - interval '30 days') AS won_last_30_days
65
+ FROM public.crm_status_log
66
+ WHERE changed_at >= now() - interval '30 days'
67
+ )
68
+ SELECT
69
+ contacts.total_count,
70
+ contacts.lead_count,
71
+ contacts.qualified_count,
72
+ contacts.proposal_count,
73
+ contacts.won_count,
74
+ contacts.lost_count,
75
+ activity.qualified_last_30_days,
76
+ activity.won_last_30_days,
77
+ contacts.new_last_7_days,
78
+ contacts.new_last_30_days,
79
+ contacts.new_last_year
80
+ FROM contact_counts contacts
81
+ CROSS JOIN activity_counts activity;
82
+ $$;
83
+
84
+ CREATE OR REPLACE FUNCTION public.search_crm_status_timeline(
85
+ p_search text,
86
+ p_since timestamptz,
87
+ p_contact_id uuid DEFAULT NULL,
88
+ p_limit integer DEFAULT 10
89
+ )
90
+ RETURNS TABLE (
91
+ id uuid,
92
+ contact_id uuid,
93
+ previous_status text,
94
+ new_status text,
95
+ reason text,
96
+ changed_at timestamptz,
97
+ changed_by_user_id uuid,
98
+ changed_by_label text,
99
+ contact_label text
100
+ )
101
+ LANGUAGE sql
102
+ STABLE
103
+ SECURITY INVOKER
104
+ SET search_path = public, pg_temp
105
+ AS $$
106
+ SELECT
107
+ log.id,
108
+ log.contact_id,
109
+ log.previous_status::text,
110
+ log.new_status::text,
111
+ log.reason,
112
+ log.changed_at,
113
+ log.changed_by_user_id,
114
+ nullif(trim(concat_ws(' ', actor.first_name, actor.last_name)), '') AS changed_by_label,
115
+ coalesce(
116
+ nullif(trim(concat_ws(' ', contact.first_name, contact.last_name)), ''),
117
+ contact.email,
118
+ 'Contacto'
119
+ ) AS contact_label
120
+ FROM public.crm_status_log log
121
+ JOIN public.crm_contacts contact ON contact.id = log.contact_id
122
+ LEFT JOIN public.profiles actor ON actor.user_id = log.changed_by_user_id
123
+ WHERE log.changed_at >= p_since
124
+ AND (p_contact_id IS NULL OR log.contact_id = p_contact_id)
125
+ AND (
126
+ nullif(trim(p_search), '') IS NULL
127
+ OR contact.first_name ILIKE '%' || trim(p_search) || '%'
128
+ OR contact.last_name ILIKE '%' || trim(p_search) || '%'
129
+ OR concat_ws(' ', contact.first_name, contact.last_name) ILIKE '%' || trim(p_search) || '%'
130
+ OR contact.email ILIKE '%' || trim(p_search) || '%'
131
+ OR actor.first_name ILIKE '%' || trim(p_search) || '%'
132
+ OR actor.last_name ILIKE '%' || trim(p_search) || '%'
133
+ OR concat_ws(' ', actor.first_name, actor.last_name) ILIKE '%' || trim(p_search) || '%'
134
+ OR actor.email ILIKE '%' || trim(p_search) || '%'
135
+ OR log.reason ILIKE '%' || trim(p_search) || '%'
136
+ OR log.new_status::text ILIKE '%' || trim(p_search) || '%'
137
+ OR CASE log.new_status::text
138
+ WHEN 'lead' THEN 'novo'
139
+ WHEN 'qualified' THEN 'qualificado'
140
+ WHEN 'proposal' THEN 'proposta'
141
+ WHEN 'won' THEN 'ganho'
142
+ WHEN 'lost' THEN 'perdido'
143
+ ELSE log.new_status::text
144
+ END ILIKE '%' || trim(p_search) || '%'
145
+ )
146
+ ORDER BY log.changed_at DESC
147
+ LIMIT least(greatest(coalesce(p_limit, 10), 1), 100);
148
+ $$;
149
+
150
+ REVOKE ALL ON FUNCTION public.get_crm_contact_stats() FROM PUBLIC;
151
+ REVOKE ALL ON FUNCTION public.search_crm_status_timeline(text, timestamptz, uuid, integer) FROM PUBLIC;
152
+ GRANT EXECUTE ON FUNCTION public.get_crm_contact_stats() TO authenticated;
153
+ GRANT EXECUTE ON FUNCTION public.search_crm_status_timeline(text, timestamptz, uuid, integer) TO authenticated;
154
+ GRANT EXECUTE ON FUNCTION public.get_crm_contact_stats() TO service_role;
155
+ GRANT EXECUTE ON FUNCTION public.search_crm_status_timeline(text, timestamptz, uuid, integer) TO service_role;
@@ -0,0 +1,39 @@
1
+ CREATE OR REPLACE FUNCTION public.reorder_marketing_topics(p_topic_ids uuid[])
2
+ RETURNS void
3
+ LANGUAGE plpgsql
4
+ SECURITY INVOKER
5
+ SET search_path = public
6
+ AS $$
7
+ DECLARE
8
+ selected_count integer;
9
+ BEGIN
10
+ IF p_topic_ids IS NULL OR cardinality(p_topic_ids) = 0 THEN
11
+ RAISE EXCEPTION 'Topic order is required.';
12
+ END IF;
13
+
14
+ IF cardinality(p_topic_ids) <> (
15
+ SELECT count(DISTINCT ordered.topic_id)
16
+ FROM unnest(p_topic_ids) AS ordered(topic_id)
17
+ ) THEN
18
+ RAISE EXCEPTION 'Topic order must contain unique topic IDs.';
19
+ END IF;
20
+
21
+ SELECT count(*)
22
+ INTO selected_count
23
+ FROM public.marketing_topics
24
+ WHERE id = ANY(p_topic_ids);
25
+
26
+ IF selected_count <> cardinality(p_topic_ids)
27
+ OR selected_count <> (SELECT count(*) FROM public.marketing_topics) THEN
28
+ RAISE EXCEPTION 'Topic order must contain every topic exactly once.';
29
+ END IF;
30
+
31
+ UPDATE public.marketing_topics AS topic
32
+ SET position = (ordered.ordinality - 1) * 10
33
+ FROM unnest(p_topic_ids) WITH ORDINALITY AS ordered(topic_id, ordinality)
34
+ WHERE topic.id = ordered.topic_id;
35
+ END;
36
+ $$;
37
+
38
+ REVOKE ALL ON FUNCTION public.reorder_marketing_topics(uuid[]) FROM PUBLIC, anon, authenticated;
39
+ GRANT EXECUTE ON FUNCTION public.reorder_marketing_topics(uuid[]) TO service_role;
@@ -0,0 +1,161 @@
1
+ -- Support paginated top-bar search and status filters across Marketing.
2
+ CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA extensions;
3
+
4
+ CREATE INDEX IF NOT EXISTS marketing_campaigns_name_trgm_idx
5
+ ON public.marketing_campaigns USING gin (name extensions.gin_trgm_ops);
6
+ CREATE INDEX IF NOT EXISTS marketing_campaigns_subject_trgm_idx
7
+ ON public.marketing_campaigns USING gin (subject extensions.gin_trgm_ops);
8
+ CREATE INDEX IF NOT EXISTS marketing_campaigns_status_created_idx
9
+ ON public.marketing_campaigns (status, created_at DESC);
10
+ CREATE INDEX IF NOT EXISTS marketing_campaigns_created_idx
11
+ ON public.marketing_campaigns (created_at DESC);
12
+ CREATE INDEX IF NOT EXISTS marketing_segments_name_trgm_idx
13
+ ON public.marketing_segments USING gin (name extensions.gin_trgm_ops);
14
+ CREATE INDEX IF NOT EXISTS marketing_segments_description_trgm_idx
15
+ ON public.marketing_segments USING gin (description extensions.gin_trgm_ops);
16
+ CREATE INDEX IF NOT EXISTS marketing_workflows_name_trgm_idx
17
+ ON public.marketing_workflows USING gin (name extensions.gin_trgm_ops);
18
+ CREATE INDEX IF NOT EXISTS marketing_workflows_description_trgm_idx
19
+ ON public.marketing_workflows USING gin (description extensions.gin_trgm_ops);
20
+ CREATE INDEX IF NOT EXISTS marketing_workflows_status_created_idx
21
+ ON public.marketing_workflows (status, created_at DESC);
22
+ CREATE INDEX IF NOT EXISTS marketing_workflows_created_idx
23
+ ON public.marketing_workflows (created_at DESC);
24
+ CREATE INDEX IF NOT EXISTS marketing_message_events_campaign_occurred_idx
25
+ ON public.marketing_message_events (campaign_id, occurred_at DESC);
26
+
27
+ CREATE OR REPLACE FUNCTION public.get_marketing_overview_metrics(p_since timestamptz DEFAULT NULL)
28
+ RETURNS TABLE (
29
+ sent_count bigint,
30
+ delivered_count bigint,
31
+ opened_count bigint,
32
+ clicked_count bigint,
33
+ unsubscribed_count bigint,
34
+ bounced_count bigint,
35
+ complained_count bigint,
36
+ raw_sent_count bigint,
37
+ raw_delivered_count bigint,
38
+ raw_opened_count bigint,
39
+ raw_clicked_count bigint,
40
+ raw_unsubscribed_count bigint,
41
+ raw_bounced_count bigint,
42
+ raw_complained_count bigint
43
+ )
44
+ LANGUAGE sql
45
+ STABLE
46
+ SECURITY INVOKER
47
+ SET search_path = public, pg_temp
48
+ AS $$
49
+ WITH recipient_counts AS (
50
+ SELECT count(*) AS sent_count
51
+ FROM public.marketing_campaign_recipients
52
+ WHERE sent_at IS NOT NULL AND (p_since IS NULL OR sent_at >= p_since)
53
+ ),
54
+ event_counts AS (
55
+ SELECT
56
+ count(DISTINCT coalesce(recipient_id::text, id::text)) FILTER (WHERE event_type = 'delivered') AS delivered_count,
57
+ count(DISTINCT coalesce(recipient_id::text, id::text)) FILTER (WHERE event_type = 'opened') AS opened_count,
58
+ count(DISTINCT coalesce(recipient_id::text, id::text)) FILTER (WHERE event_type = 'clicked') AS clicked_count,
59
+ count(DISTINCT coalesce(recipient_id::text, id::text)) FILTER (WHERE event_type = 'unsubscribed') AS unsubscribed_count,
60
+ count(DISTINCT coalesce(recipient_id::text, id::text)) FILTER (WHERE event_type = 'bounced') AS bounced_count,
61
+ count(DISTINCT coalesce(recipient_id::text, id::text)) FILTER (WHERE event_type = 'complained') AS complained_count,
62
+ count(*) FILTER (WHERE event_type = 'sent') AS raw_sent_count,
63
+ count(*) FILTER (WHERE event_type = 'delivered') AS raw_delivered_count,
64
+ count(*) FILTER (WHERE event_type = 'opened') AS raw_opened_count,
65
+ count(*) FILTER (WHERE event_type = 'clicked') AS raw_clicked_count,
66
+ count(*) FILTER (WHERE event_type = 'unsubscribed') AS raw_unsubscribed_count,
67
+ count(*) FILTER (WHERE event_type = 'bounced') AS raw_bounced_count,
68
+ count(*) FILTER (WHERE event_type = 'complained') AS raw_complained_count
69
+ FROM public.marketing_message_events
70
+ WHERE (p_since IS NULL OR occurred_at >= p_since)
71
+ )
72
+ SELECT recipients.sent_count, events.*
73
+ FROM recipient_counts recipients CROSS JOIN event_counts events;
74
+ $$;
75
+
76
+ CREATE OR REPLACE FUNCTION public.get_marketing_campaign_metrics(p_campaign_id uuid)
77
+ RETURNS TABLE (
78
+ sent_count bigint,
79
+ delivered_count bigint,
80
+ opened_count bigint,
81
+ clicked_count bigint,
82
+ unsubscribed_count bigint,
83
+ bounced_count bigint,
84
+ complained_count bigint,
85
+ raw_sent_count bigint,
86
+ raw_delivered_count bigint,
87
+ raw_opened_count bigint,
88
+ raw_clicked_count bigint,
89
+ raw_unsubscribed_count bigint,
90
+ raw_bounced_count bigint,
91
+ raw_complained_count bigint,
92
+ queued_count bigint,
93
+ sending_count bigint,
94
+ recipient_sent_count bigint,
95
+ failed_count bigint,
96
+ suppressed_count bigint,
97
+ skipped_count bigint
98
+ )
99
+ LANGUAGE sql
100
+ STABLE
101
+ SECURITY INVOKER
102
+ SET search_path = public, pg_temp
103
+ AS $$
104
+ WITH recipient_counts AS (
105
+ SELECT
106
+ count(*) FILTER (WHERE sent_at IS NOT NULL) AS sent_count,
107
+ count(*) FILTER (WHERE status = 'queued') AS queued_count,
108
+ count(*) FILTER (WHERE status = 'sending') AS sending_count,
109
+ count(*) FILTER (WHERE status = 'sent') AS recipient_sent_count,
110
+ count(*) FILTER (WHERE status = 'failed') AS failed_count,
111
+ count(*) FILTER (WHERE status = 'suppressed') AS suppressed_count,
112
+ count(*) FILTER (WHERE status = 'skipped') AS skipped_count
113
+ FROM public.marketing_campaign_recipients
114
+ WHERE campaign_id = p_campaign_id
115
+ ),
116
+ event_counts AS (
117
+ SELECT
118
+ count(DISTINCT coalesce(recipient_id::text, id::text)) FILTER (WHERE event_type = 'delivered') AS delivered_count,
119
+ count(DISTINCT coalesce(recipient_id::text, id::text)) FILTER (WHERE event_type = 'opened') AS opened_count,
120
+ count(DISTINCT coalesce(recipient_id::text, id::text)) FILTER (WHERE event_type = 'clicked') AS clicked_count,
121
+ count(DISTINCT coalesce(recipient_id::text, id::text)) FILTER (WHERE event_type = 'unsubscribed') AS unsubscribed_count,
122
+ count(DISTINCT coalesce(recipient_id::text, id::text)) FILTER (WHERE event_type = 'bounced') AS bounced_count,
123
+ count(DISTINCT coalesce(recipient_id::text, id::text)) FILTER (WHERE event_type = 'complained') AS complained_count,
124
+ count(*) FILTER (WHERE event_type = 'sent') AS raw_sent_count,
125
+ count(*) FILTER (WHERE event_type = 'delivered') AS raw_delivered_count,
126
+ count(*) FILTER (WHERE event_type = 'opened') AS raw_opened_count,
127
+ count(*) FILTER (WHERE event_type = 'clicked') AS raw_clicked_count,
128
+ count(*) FILTER (WHERE event_type = 'unsubscribed') AS raw_unsubscribed_count,
129
+ count(*) FILTER (WHERE event_type = 'bounced') AS raw_bounced_count,
130
+ count(*) FILTER (WHERE event_type = 'complained') AS raw_complained_count
131
+ FROM public.marketing_message_events
132
+ WHERE campaign_id = p_campaign_id
133
+ )
134
+ SELECT
135
+ recipients.sent_count,
136
+ events.delivered_count,
137
+ events.opened_count,
138
+ events.clicked_count,
139
+ events.unsubscribed_count,
140
+ events.bounced_count,
141
+ events.complained_count,
142
+ events.raw_sent_count,
143
+ events.raw_delivered_count,
144
+ events.raw_opened_count,
145
+ events.raw_clicked_count,
146
+ events.raw_unsubscribed_count,
147
+ events.raw_bounced_count,
148
+ events.raw_complained_count,
149
+ recipients.queued_count,
150
+ recipients.sending_count,
151
+ recipients.recipient_sent_count,
152
+ recipients.failed_count,
153
+ recipients.suppressed_count,
154
+ recipients.skipped_count
155
+ FROM recipient_counts recipients CROSS JOIN event_counts events;
156
+ $$;
157
+
158
+ REVOKE ALL ON FUNCTION public.get_marketing_overview_metrics(timestamptz) FROM PUBLIC, anon, authenticated;
159
+ REVOKE ALL ON FUNCTION public.get_marketing_campaign_metrics(uuid) FROM PUBLIC, anon, authenticated;
160
+ GRANT EXECUTE ON FUNCTION public.get_marketing_overview_metrics(timestamptz) TO service_role;
161
+ GRANT EXECUTE ON FUNCTION public.get_marketing_campaign_metrics(uuid) TO service_role;
@@ -0,0 +1,5 @@
1
+ -- Support authoritative organization search used by CRM and Projects pickers.
2
+ CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA extensions;
3
+
4
+ CREATE INDEX IF NOT EXISTS organizations_name_trgm_idx
5
+ ON public.organizations USING gin (name extensions.gin_trgm_ops);
@@ -0,0 +1,20 @@
1
+ -- Aggregate portfolio task counters in Postgres so list and dashboard requests
2
+ -- transfer one row per project instead of every task row.
3
+
4
+ CREATE OR REPLACE VIEW public.project_task_stats
5
+ WITH (security_invoker = true)
6
+ AS
7
+ SELECT
8
+ project_id,
9
+ count(*) AS total,
10
+ count(*) FILTER (WHERE status = 'done') AS done,
11
+ count(*) FILTER (
12
+ WHERE due_date < CURRENT_DATE
13
+ AND status <> 'done'
14
+ ) AS overdue,
15
+ count(*) FILTER (WHERE status = 'blocked') AS blocked
16
+ FROM public.project_tasks
17
+ GROUP BY project_id;
18
+
19
+ REVOKE ALL ON TABLE public.project_task_stats FROM PUBLIC, anon;
20
+ GRANT SELECT ON TABLE public.project_task_stats TO authenticated, service_role;
@@ -0,0 +1,11 @@
1
+ -- Support project collection search, filters, and newest-first ordering.
2
+ CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA extensions;
3
+
4
+ CREATE INDEX IF NOT EXISTS projects_name_trgm_idx
5
+ ON public.projects USING gin (name extensions.gin_trgm_ops);
6
+ CREATE INDEX IF NOT EXISTS projects_code_trgm_idx
7
+ ON public.projects USING gin (code extensions.gin_trgm_ops);
8
+ CREATE INDEX IF NOT EXISTS projects_status_updated_idx
9
+ ON public.projects (status, updated_at DESC);
10
+ CREATE INDEX IF NOT EXISTS projects_health_updated_idx
11
+ ON public.projects (health, updated_at DESC);