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 +1 -1
- package/src/constants.mjs +10 -8
- package/src/generator.mjs +4 -1
- package/template/base/app/(shell)/dashboard/dashboard-live-mount.tsx +5 -5
- package/template/base/config/module-toolbar-controls.tsx +2 -0
- package/template/modules/marketing/app/api/marketing/_handlers.ts +9 -0
- package/template/modules/marketing/app/api/marketing/topics/[id]/route.ts +2 -0
- package/template/modules/marketing/app/api/marketing/topics/order/route.ts +1 -0
- package/template/modules/marketing/app/api/marketing/topics/route.ts +1 -1
- package/template/supabase/modules/core/migrations/20260731130000_profile_search_indexes.sql +13 -0
- package/template/supabase/modules/crm/migrations/20260731130200_crm_collection_indexes.sql +155 -0
- package/template/supabase/modules/marketing/migrations/20260731124500_marketing_topic_ordering.sql +39 -0
- package/template/supabase/modules/marketing/migrations/20260731130400_marketing_collection_indexes.sql +161 -0
- package/template/supabase/modules/orgs/migrations/20260731130100_organization_search_indexes.sql +5 -0
- package/template/supabase/modules/projects/migrations/20260731124000_project_task_stats.sql +20 -0
- package/template/supabase/modules/projects/migrations/20260731130300_project_collection_indexes.sql +11 -0
package/package.json
CHANGED
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.
|
|
175
|
-
"@brightweblabs/core-auth": "^0.9.
|
|
176
|
-
"@brightweblabs/infra": "^0.
|
|
177
|
-
"@brightweblabs/module-admin": "^0.8.
|
|
178
|
-
"@brightweblabs/module-crm": "^0.
|
|
179
|
-
"@brightweblabs/module-marketing": "^0.
|
|
180
|
-
"@brightweblabs/module-orgs": "^0.3.
|
|
181
|
-
"@brightweblabs/module-projects": "^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 @@
|
|
|
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;
|
package/template/supabase/modules/marketing/migrations/20260731124500_marketing_topic_ordering.sql
ADDED
|
@@ -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,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;
|
package/template/supabase/modules/projects/migrations/20260731130300_project_collection_indexes.sql
ADDED
|
@@ -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);
|