create-bw-app 0.20.0 → 0.22.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.20.0",
4
+ "version": "0.22.0",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "create-bw-app": "bin/create-bw-app.mjs",
package/src/constants.mjs CHANGED
@@ -171,16 +171,16 @@ export const PLATFORM_STARTER_FILES = [
171
171
  ];
172
172
 
173
173
  export const APP_DEPENDENCY_DEFAULTS = {
174
- "@brightweblabs/app-shell": "^0.8.0",
175
- "@brightweblabs/core-auth": "^0.7.3",
176
- "@brightweblabs/infra": "^0.4.0",
177
- "@brightweblabs/module-admin": "^0.5.11",
178
- "@brightweblabs/module-crm": "^0.10.1",
179
- "@brightweblabs/module-marketing": "^0.2.10",
180
- "@brightweblabs/module-orgs": "^0.3.11",
181
- "@brightweblabs/module-projects": "^0.9.2",
182
- "@brightweblabs/theme": "^0.5.1",
183
- "@brightweblabs/ui": "^1.2.0",
174
+ "@brightweblabs/app-shell": "^0.9.0",
175
+ "@brightweblabs/core-auth": "^0.9.0",
176
+ "@brightweblabs/infra": "^0.5.0",
177
+ "@brightweblabs/module-admin": "^0.7.0",
178
+ "@brightweblabs/module-crm": "^0.11.0",
179
+ "@brightweblabs/module-marketing": "^0.2.12",
180
+ "@brightweblabs/module-orgs": "^0.3.13",
181
+ "@brightweblabs/module-projects": "^0.10.0",
182
+ "@brightweblabs/theme": "^0.7.0",
183
+ "@brightweblabs/ui": "^1.4.0",
184
184
  "geist": "1.7.2",
185
185
  "lucide-react": "^1.8.0",
186
186
  "next": "^16.0.0",
package/src/generator.mjs CHANGED
@@ -1021,33 +1021,39 @@ export function createShellConfig(selectedModules) {
1021
1021
 
1022
1022
  export function createModuleToolbarControlsConfig(selectedModules) {
1023
1023
  const imports = [];
1024
- const branches = [];
1024
+ const entries = [];
1025
1025
 
1026
1026
  if (selectedModules.includes("admin")) {
1027
1027
  imports.push('import { AdminToolbarControls } from "@brightweblabs/module-admin/ui";');
1028
- branches.push(' if (pathname === "/admin/users") return <AdminToolbarControls />;');
1028
+ entries.push(' "admin-users": () => <AdminToolbarControls />,');
1029
1029
  }
1030
1030
  if (selectedModules.includes("crm")) {
1031
1031
  imports.push('import { CrmToolbarControls } from "@brightweblabs/module-crm/ui";');
1032
- branches.push(' if (pathname === "/crm") return <CrmToolbarControls />;');
1032
+ entries.push(' crm: () => <CrmToolbarControls />,');
1033
1033
  }
1034
1034
  if (selectedModules.includes("projects")) {
1035
1035
  imports.push('import { ProjectsToolbarControls } from "@brightweblabs/module-projects/ui";');
1036
- branches.push(' if (pathname === projectsBaseHref) return <ProjectsToolbarControls />;');
1036
+ entries.push(' projects: () => <ProjectsToolbarControls />,');
1037
1037
  }
1038
1038
 
1039
1039
  return [
1040
1040
  '"use client";',
1041
1041
  "",
1042
+ 'import type { ReactNode } from "react";',
1043
+ 'import { resolveShellToolbarSurface, type ShellToolbarRouteConfig, type ShellToolbarSurface } from "@brightweblabs/app-shell";',
1042
1044
  "// MANAGED BY BRIGHTWEB — regenerated when modules are added, removed, or updated.",
1043
1045
  ...imports,
1044
- imports.length > 0 ? "" : null,
1045
- "export function getModuleToolbarControls(pathname: string, projectsBaseHref: string) {",
1046
- ...branches,
1047
- " return null;",
1046
+ "",
1047
+ "const toolbarControlBySurface: Partial<Record<ShellToolbarSurface, () => ReactNode>> = {",
1048
+ ...entries,
1049
+ "};",
1050
+ "",
1051
+ "export function getModuleToolbarControls(pathname: string, toolbarRoutes: ShellToolbarRouteConfig[]) {",
1052
+ " const surface = resolveShellToolbarSurface(pathname, toolbarRoutes);",
1053
+ " return surface ? toolbarControlBySurface[surface]?.() ?? null : null;",
1048
1054
  "}",
1049
1055
  "",
1050
- ].filter((line) => line !== null).join("\n");
1056
+ ].join("\n");
1051
1057
  }
1052
1058
 
1053
1059
  function createOrganizationRoute(methods, enabled) {
@@ -1088,7 +1094,7 @@ export function createOptionalModuleRouteFiles(selectedModules) {
1088
1094
 
1089
1095
  if (adminEnabled) {
1090
1096
  dependencyImports.push(
1091
- 'import { getAdminUserInvitationDetails, registerUserFromAdminInvitation } from "@brightweblabs/module-admin";',
1097
+ 'import { acceptAdminUserInvitation, getAdminUserInvitationDetails, registerUserFromAdminInvitation } from "@brightweblabs/module-admin";',
1092
1098
  );
1093
1099
  }
1094
1100
  if (orgsEnabled) {
@@ -1107,6 +1113,7 @@ export function createOptionalModuleRouteFiles(selectedModules) {
1107
1113
  'import { requireServerUserAccess } from "@brightweblabs/core-auth/server";',
1108
1114
  'import { requireServiceRoleClient } from "@brightweblabs/infra/server";',
1109
1115
  "import {",
1116
+ " acceptAdminUserInvitation,",
1110
1117
  " getAdminUserInvitationDetails,",
1111
1118
  " registerUserFromAdminInvitation,",
1112
1119
  '} from "@brightweblabs/module-admin";',
@@ -1132,6 +1139,7 @@ export function createOptionalModuleRouteFiles(selectedModules) {
1132
1139
  " ensureCrmContactForProfile,",
1133
1140
  " }),",
1134
1141
  " registerAdminInvitation: registerUserFromAdminInvitation,",
1142
+ " acceptAdminInvitation: acceptAdminUserInvitation,",
1135
1143
  " acceptOrganizationInvitation: (client: never, input: {",
1136
1144
  " invitationId: string;",
1137
1145
  " profileId: string;",
@@ -1174,6 +1182,7 @@ export function createOptionalModuleRouteFiles(selectedModules) {
1174
1182
  " ensureCrmContactForProfile,",
1175
1183
  " }),",
1176
1184
  " registerAdminInvitation: registerUserFromAdminInvitation,",
1185
+ ...(adminEnabled ? [" acceptAdminInvitation: acceptAdminUserInvitation,"] : []),
1177
1186
  " acceptOrganizationInvitation: (client: never, input: {",
1178
1187
  " invitationId: string;",
1179
1188
  " profileId: string;",
@@ -5,7 +5,7 @@ import { ShellLayoutClient, type ShellViewer } from "./shell-layout-client";
5
5
  export const dynamic = "force-dynamic";
6
6
 
7
7
  export default async function ShellLayout({ children }: Readonly<{ children: ReactNode }>) {
8
- const { supabase, user, role } = await requireServerPageAccess();
8
+ const { profileId, supabase, user, role } = await requireServerPageAccess();
9
9
  const { data: profile } = await supabase
10
10
  .from("profiles")
11
11
  .select("first_name, last_name")
@@ -13,6 +13,7 @@ export default async function ShellLayout({ children }: Readonly<{ children: Rea
13
13
  .maybeSingle<{ first_name: string | null; last_name: string | null }>();
14
14
 
15
15
  const viewer: ShellViewer = {
16
+ profileId,
16
17
  email: user.email ?? null,
17
18
  firstName: profile?.first_name ?? null,
18
19
  lastName: profile?.last_name ?? null,
@@ -8,10 +8,12 @@ import {
8
8
  DesktopSidebar,
9
9
  MobileNav,
10
10
  ShellActionsProvider,
11
+ ShellRealtimeBridge,
11
12
  computeInitials,
12
13
  isShellNavItemActive,
13
14
  useShellAction,
14
15
  useShellActionDispatch,
16
+ useShellNotifications,
15
17
  useShellNavState,
16
18
  type ShellContextualAction,
17
19
  type ShellNavStateGroup,
@@ -23,6 +25,7 @@ import { getStarterShellConfig } from "../../config/shell";
23
25
  import "@brightweblabs/app-shell/dashboard.css";
24
26
 
25
27
  export type ShellViewer = {
28
+ profileId: string | null;
26
29
  email: string | null;
27
30
  firstName: string | null;
28
31
  lastName: string | null;
@@ -31,18 +34,12 @@ export type ShellViewer = {
31
34
  };
32
35
 
33
36
  const authClient = createAuthUiClient();
34
- const toolbarWindowEventByAction: Record<string, string> = {
35
- "projects-refresh": "projects:refresh",
36
- "projects-new-menu": "projects:open-new-project",
37
- "crm-create-menu": "brightweb:crm:create-contact",
38
- };
39
-
40
37
  export function ShellLayoutClient({
41
38
  children,
42
39
  viewer,
43
40
  }: Readonly<{ children: ReactNode; viewer: ShellViewer }>) {
44
41
  return (
45
- <ShellActionsProvider aliases={toolbarWindowEventByAction}>
42
+ <ShellActionsProvider>
46
43
  <ShellLayoutInner viewer={viewer}>{children}</ShellLayoutInner>
47
44
  </ShellActionsProvider>
48
45
  );
@@ -67,6 +64,7 @@ function ShellLayoutInner({
67
64
  );
68
65
  const { isSidebarCollapsed, isGroupOpen, toggleGroup, toggleSidebar } =
69
66
  useShellNavState({ pathname, groups: shellGroups });
67
+ const notifications = useShellNotifications({ enabled: viewer.isStaff });
70
68
  const isAdminSurface = pathname === "/admin" || pathname.startsWith("/admin/");
71
69
  const shellNavItems = [
72
70
  ...config.primaryNav,
@@ -87,7 +85,7 @@ function ShellLayoutInner({
87
85
  viewer.email ||
88
86
  "Conta";
89
87
  const projectsBaseHref = pathname.startsWith("/projects") ? "/projects" : "/projetos";
90
- const toolbarControls = getModuleToolbarControls(pathname, projectsBaseHref);
88
+ const toolbarControls = getModuleToolbarControls(pathname, toolbarRoutes);
91
89
 
92
90
  const dispatchShellAction = useShellActionDispatch();
93
91
  useShellAction("projects-back-to-portfolio", () => {
@@ -156,7 +154,7 @@ function ShellLayoutInner({
156
154
  toolbarRoutes={toolbarRoutes}
157
155
  toolbarActions={toolbarActions}
158
156
  onToolbarAction={handleToolbarAction}
159
- notifications={{}}
157
+ notifications={viewer.isStaff ? notifications : undefined}
160
158
  >
161
159
  {toolbarControls}
162
160
  </AppHeader>
@@ -174,6 +172,7 @@ function ShellLayoutInner({
174
172
  />
175
173
  }
176
174
  >
175
+ <ShellRealtimeBridge viewer={viewer} />
177
176
  {children}
178
177
  {isAdminSurface || pathname === "/account" ? <Toaster /> : null}
179
178
  </AppShellFrame>
@@ -1,6 +1,7 @@
1
1
  import { requireServerUserAccess } from "@brightweblabs/core-auth/server";
2
2
  import { requireServiceRoleClient } from "@brightweblabs/infra/server";
3
3
  import {
4
+ acceptAdminUserInvitation,
4
5
  getAdminUserInvitationDetails,
5
6
  registerUserFromAdminInvitation,
6
7
  } from "@brightweblabs/module-admin";
@@ -26,6 +27,7 @@ export const invitationHttpDependencies = {
26
27
  ensureCrmContactForProfile,
27
28
  }),
28
29
  registerAdminInvitation: registerUserFromAdminInvitation,
30
+ acceptAdminInvitation: acceptAdminUserInvitation,
29
31
  acceptOrganizationInvitation: (client: never, input: {
30
32
  invitationId: string;
31
33
  profileId: string;
@@ -0,0 +1 @@
1
+ export { handleNotificationsGetRequest as GET, handleNotificationsPostRequest as POST } from "@brightweblabs/core-auth/notifications";
@@ -1,6 +1,19 @@
1
1
  "use client";
2
2
 
3
+ import type { ReactNode } from "react";
4
+ import { resolveShellToolbarSurface, type ShellToolbarRouteConfig, type ShellToolbarSurface } from "@brightweblabs/app-shell";
5
+ import { AdminToolbarControls } from "@brightweblabs/module-admin/ui";
6
+ import { CrmToolbarControls } from "@brightweblabs/module-crm/ui";
7
+ import { ProjectsToolbarControls } from "@brightweblabs/module-projects/ui";
8
+
3
9
  // MANAGED BY BRIGHTWEB — regenerated when modules are added, removed, or updated.
4
- export function getModuleToolbarControls(_pathname: string, _projectsBaseHref: string) {
5
- return null;
10
+ const toolbarControlBySurface: Partial<Record<ShellToolbarSurface, () => ReactNode>> = {
11
+ "admin-users": () => <AdminToolbarControls />,
12
+ crm: () => <CrmToolbarControls />,
13
+ projects: () => <ProjectsToolbarControls />,
14
+ };
15
+
16
+ export function getModuleToolbarControls(pathname: string, toolbarRoutes: ShellToolbarRouteConfig[]) {
17
+ const surface = resolveShellToolbarSurface(pathname, toolbarRoutes);
18
+ return surface ? toolbarControlBySurface[surface]?.() ?? null : null;
6
19
  }
@@ -0,0 +1,51 @@
1
+ -- Admin activity is visible only to admins. Other non-project activity remains
2
+ -- visible to staff/admin; the Projects module installs its own audience rules.
3
+
4
+ CREATE OR REPLACE FUNCTION public.can_profile_view_app_activity_event(
5
+ p_profile_id uuid,
6
+ p_domain text,
7
+ p_entity_table text,
8
+ p_entity_id uuid,
9
+ p_payload jsonb DEFAULT '{}'::jsonb
10
+ )
11
+ RETURNS boolean
12
+ LANGUAGE plpgsql
13
+ STABLE
14
+ SECURITY DEFINER
15
+ SET search_path = public
16
+ AS $$
17
+ DECLARE
18
+ v_role text;
19
+ BEGIN
20
+ IF p_profile_id IS NULL THEN
21
+ RETURN false;
22
+ END IF;
23
+
24
+ SELECT assignment.role_code
25
+ INTO v_role
26
+ FROM public.user_role_assignments assignment
27
+ WHERE assignment.profile_id = p_profile_id
28
+ LIMIT 1;
29
+
30
+ IF p_domain = 'admin' THEN
31
+ RETURN v_role = 'admin';
32
+ END IF;
33
+
34
+ RETURN v_role = ANY (ARRAY['staff', 'admin']);
35
+ END;
36
+ $$;
37
+
38
+ REVOKE ALL ON FUNCTION public.can_profile_view_app_activity_event(uuid, text, text, uuid, jsonb)
39
+ FROM PUBLIC, anon, authenticated;
40
+ GRANT EXECUTE ON FUNCTION public.can_profile_view_app_activity_event(uuid, text, text, uuid, jsonb)
41
+ TO service_role;
42
+
43
+ DROP POLICY IF EXISTS "Staff can view app activity events" ON public.app_activity_events;
44
+ CREATE POLICY "Staff can view app activity events"
45
+ ON public.app_activity_events
46
+ FOR SELECT
47
+ TO authenticated
48
+ USING (
49
+ public.is_staff()
50
+ AND public.can_view_app_activity_event(domain, entity_table, entity_id, payload)
51
+ );
@@ -0,0 +1,165 @@
1
+ -- Shared notification inbox primitives. Module-specific migrations may replace
2
+ -- can_profile_view_app_activity_event to add audience rules for their domains.
3
+
4
+ DO $$
5
+ BEGIN
6
+ IF EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'supabase_realtime')
7
+ AND NOT EXISTS (
8
+ SELECT 1
9
+ FROM pg_publication_tables
10
+ WHERE pubname = 'supabase_realtime'
11
+ AND schemaname = 'public'
12
+ AND tablename = 'app_activity_events'
13
+ )
14
+ THEN
15
+ ALTER PUBLICATION supabase_realtime ADD TABLE public.app_activity_events;
16
+ END IF;
17
+ END;
18
+ $$;
19
+
20
+ CREATE OR REPLACE FUNCTION public.can_profile_view_app_activity_event(
21
+ p_profile_id uuid,
22
+ p_domain text,
23
+ p_entity_table text,
24
+ p_entity_id uuid,
25
+ p_payload jsonb DEFAULT '{}'::jsonb
26
+ )
27
+ RETURNS boolean
28
+ LANGUAGE plpgsql
29
+ STABLE
30
+ SECURITY DEFINER
31
+ SET search_path = public
32
+ AS $$
33
+ DECLARE
34
+ v_payload jsonb := COALESCE(p_payload, '{}'::jsonb);
35
+ BEGIN
36
+ IF p_profile_id IS NULL THEN
37
+ RETURN false;
38
+ END IF;
39
+
40
+ IF jsonb_typeof(v_payload->'visible_profile_ids') = 'array' THEN
41
+ RETURN EXISTS (
42
+ SELECT 1
43
+ FROM jsonb_array_elements_text(v_payload->'visible_profile_ids') AS visible(profile_id)
44
+ WHERE visible.profile_id = p_profile_id::text
45
+ );
46
+ END IF;
47
+
48
+ -- Project audience rules are installed by the projects module.
49
+ RETURN p_domain <> 'projects';
50
+ END;
51
+ $$;
52
+
53
+ REVOKE ALL ON FUNCTION public.can_profile_view_app_activity_event(uuid, text, text, uuid, jsonb)
54
+ FROM PUBLIC, anon, authenticated;
55
+ GRANT EXECUTE ON FUNCTION public.can_profile_view_app_activity_event(uuid, text, text, uuid, jsonb)
56
+ TO service_role;
57
+
58
+ CREATE OR REPLACE FUNCTION public.can_view_app_activity_event(
59
+ p_domain text,
60
+ p_entity_table text,
61
+ p_entity_id uuid,
62
+ p_payload jsonb DEFAULT '{}'::jsonb
63
+ )
64
+ RETURNS boolean
65
+ LANGUAGE sql
66
+ STABLE
67
+ SECURITY DEFINER
68
+ SET search_path = public
69
+ AS $$
70
+ SELECT public.can_profile_view_app_activity_event(
71
+ public.current_profile_id(),
72
+ p_domain,
73
+ p_entity_table,
74
+ p_entity_id,
75
+ p_payload
76
+ )
77
+ $$;
78
+
79
+ REVOKE ALL ON FUNCTION public.can_view_app_activity_event(text, text, uuid, jsonb)
80
+ FROM PUBLIC, anon;
81
+ GRANT EXECUTE ON FUNCTION public.can_view_app_activity_event(text, text, uuid, jsonb)
82
+ TO authenticated;
83
+
84
+ DROP POLICY IF EXISTS "Staff can view app activity events" ON public.app_activity_events;
85
+ CREATE POLICY "Staff can view app activity events"
86
+ ON public.app_activity_events
87
+ FOR SELECT
88
+ TO authenticated
89
+ USING (
90
+ public.is_staff()
91
+ AND public.can_view_app_activity_event(domain, entity_table, entity_id, payload)
92
+ );
93
+
94
+ CREATE OR REPLACE FUNCTION public.current_notification_summary(p_profile_id uuid)
95
+ RETURNS TABLE(seen_at timestamptz, unread_count bigint)
96
+ LANGUAGE sql
97
+ STABLE
98
+ SECURITY DEFINER
99
+ SET search_path = public
100
+ AS $$
101
+ WITH notification_state AS (
102
+ SELECT state.alerts_seen_at
103
+ FROM public.user_notification_state state
104
+ WHERE state.profile_id = p_profile_id
105
+ LIMIT 1
106
+ )
107
+ SELECT
108
+ (SELECT alerts_seen_at FROM notification_state) AS seen_at,
109
+ count(event.id) AS unread_count
110
+ FROM public.app_activity_events event
111
+ WHERE public.can_profile_view_app_activity_event(
112
+ p_profile_id,
113
+ event.domain,
114
+ event.entity_table,
115
+ event.entity_id,
116
+ event.payload
117
+ )
118
+ AND (
119
+ (SELECT alerts_seen_at FROM notification_state) IS NULL
120
+ OR event.created_at > (SELECT alerts_seen_at FROM notification_state)
121
+ )
122
+ AND (event.actor_profile_id IS NULL OR event.actor_profile_id <> p_profile_id)
123
+ $$;
124
+
125
+ REVOKE ALL ON FUNCTION public.current_notification_summary(uuid)
126
+ FROM PUBLIC, anon, authenticated;
127
+ GRANT EXECUTE ON FUNCTION public.current_notification_summary(uuid)
128
+ TO service_role;
129
+
130
+ CREATE OR REPLACE FUNCTION public.current_notification_items(
131
+ p_profile_id uuid,
132
+ p_limit integer DEFAULT 8
133
+ )
134
+ RETURNS TABLE(
135
+ id uuid,
136
+ created_at timestamptz,
137
+ domain text,
138
+ summary text
139
+ )
140
+ LANGUAGE sql
141
+ STABLE
142
+ SECURITY DEFINER
143
+ SET search_path = public
144
+ AS $$
145
+ SELECT event.id, event.created_at, event.domain, event.summary
146
+ FROM public.app_activity_events event
147
+ LEFT JOIN public.user_notification_state state
148
+ ON state.profile_id = p_profile_id
149
+ WHERE public.can_profile_view_app_activity_event(
150
+ p_profile_id,
151
+ event.domain,
152
+ event.entity_table,
153
+ event.entity_id,
154
+ event.payload
155
+ )
156
+ AND (state.alerts_seen_at IS NULL OR event.created_at > state.alerts_seen_at)
157
+ AND (event.actor_profile_id IS NULL OR event.actor_profile_id <> p_profile_id)
158
+ ORDER BY event.created_at DESC
159
+ LIMIT LEAST(GREATEST(COALESCE(p_limit, 8), 0), 50)
160
+ $$;
161
+
162
+ REVOKE ALL ON FUNCTION public.current_notification_items(uuid, integer)
163
+ FROM PUBLIC, anon, authenticated;
164
+ GRANT EXECUTE ON FUNCTION public.current_notification_items(uuid, integer)
165
+ TO service_role;
@@ -0,0 +1,50 @@
1
+ -- Keep the notification list useful after acknowledgement. The unread summary
2
+ -- still uses alerts_seen_at; the item list returns the latest visible events.
3
+
4
+ CREATE OR REPLACE FUNCTION public.current_notification_items(
5
+ p_profile_id uuid,
6
+ p_limit integer DEFAULT 8
7
+ )
8
+ RETURNS TABLE(
9
+ id uuid,
10
+ created_at timestamptz,
11
+ domain text,
12
+ event_type text,
13
+ entity_table text,
14
+ entity_id uuid,
15
+ actor_profile_id uuid,
16
+ summary text,
17
+ payload jsonb
18
+ )
19
+ LANGUAGE sql
20
+ STABLE
21
+ SECURITY DEFINER
22
+ SET search_path = public
23
+ AS $$
24
+ SELECT
25
+ event.id,
26
+ event.created_at,
27
+ event.domain,
28
+ event.event_type,
29
+ event.entity_table,
30
+ event.entity_id,
31
+ event.actor_profile_id,
32
+ event.summary,
33
+ COALESCE(event.payload, '{}'::jsonb)
34
+ FROM public.app_activity_events event
35
+ WHERE public.can_profile_view_app_activity_event(
36
+ p_profile_id,
37
+ event.domain,
38
+ event.entity_table,
39
+ event.entity_id,
40
+ event.payload
41
+ )
42
+ AND (event.actor_profile_id IS NULL OR event.actor_profile_id <> p_profile_id)
43
+ ORDER BY event.created_at DESC
44
+ LIMIT LEAST(GREATEST(COALESCE(p_limit, 8), 0), 50)
45
+ $$;
46
+
47
+ REVOKE ALL ON FUNCTION public.current_notification_items(uuid, integer)
48
+ FROM PUBLIC, anon, authenticated;
49
+ GRANT EXECUTE ON FUNCTION public.current_notification_items(uuid, integer)
50
+ TO service_role;
@@ -0,0 +1,81 @@
1
+ -- Extend the Core notification audience contract with project membership rules.
2
+
3
+ CREATE OR REPLACE FUNCTION public.can_profile_view_app_activity_event(
4
+ p_profile_id uuid,
5
+ p_domain text,
6
+ p_entity_table text,
7
+ p_entity_id uuid,
8
+ p_payload jsonb DEFAULT '{}'::jsonb
9
+ )
10
+ RETURNS boolean
11
+ LANGUAGE plpgsql
12
+ STABLE
13
+ SECURITY DEFINER
14
+ SET search_path = public
15
+ AS $$
16
+ DECLARE
17
+ v_payload jsonb := COALESCE(p_payload, '{}'::jsonb);
18
+ v_project_id uuid;
19
+ BEGIN
20
+ IF p_profile_id IS NULL THEN
21
+ RETURN false;
22
+ END IF;
23
+
24
+ IF jsonb_typeof(v_payload->'visible_profile_ids') = 'array' THEN
25
+ RETURN EXISTS (
26
+ SELECT 1
27
+ FROM jsonb_array_elements_text(v_payload->'visible_profile_ids') AS visible(profile_id)
28
+ WHERE visible.profile_id = p_profile_id::text
29
+ );
30
+ END IF;
31
+
32
+ IF p_domain <> 'projects' THEN
33
+ RETURN true;
34
+ END IF;
35
+
36
+ BEGIN
37
+ v_project_id := NULLIF(v_payload->>'project_id', '')::uuid;
38
+ EXCEPTION WHEN invalid_text_representation THEN
39
+ v_project_id := NULL;
40
+ END;
41
+
42
+ IF v_project_id IS NULL AND p_entity_table = 'projects' THEN
43
+ v_project_id := p_entity_id;
44
+ END IF;
45
+ IF v_project_id IS NULL THEN
46
+ RETURN false;
47
+ END IF;
48
+
49
+ RETURN EXISTS (
50
+ SELECT 1
51
+ FROM public.user_role_assignments assignment
52
+ WHERE assignment.profile_id = p_profile_id
53
+ AND assignment.role_code = 'admin'
54
+ )
55
+ OR EXISTS (
56
+ SELECT 1
57
+ FROM public.projects project
58
+ WHERE project.id = v_project_id
59
+ AND project.owner_profile_id = p_profile_id
60
+ )
61
+ OR EXISTS (
62
+ SELECT 1
63
+ FROM public.project_members member
64
+ WHERE member.project_id = v_project_id
65
+ AND member.profile_id = p_profile_id
66
+ )
67
+ OR EXISTS (
68
+ SELECT 1
69
+ FROM public.projects project
70
+ JOIN public.organization_members member
71
+ ON member.organization_id = project.organization_id
72
+ WHERE project.id = v_project_id
73
+ AND member.profile_id = p_profile_id
74
+ );
75
+ END;
76
+ $$;
77
+
78
+ REVOKE ALL ON FUNCTION public.can_profile_view_app_activity_event(uuid, text, text, uuid, jsonb)
79
+ FROM PUBLIC, anon, authenticated;
80
+ GRANT EXECUTE ON FUNCTION public.can_profile_view_app_activity_event(uuid, text, text, uuid, jsonb)
81
+ TO service_role;
@@ -0,0 +1,143 @@
1
+ -- Align project activity visibility with the canonical project read policy.
2
+ -- Removed members and deleted-project audiences are retained in event payloads.
3
+
4
+ CREATE OR REPLACE FUNCTION public.can_profile_view_app_activity_event(
5
+ p_profile_id uuid,
6
+ p_domain text,
7
+ p_entity_table text,
8
+ p_entity_id uuid,
9
+ p_payload jsonb DEFAULT '{}'::jsonb
10
+ )
11
+ RETURNS boolean
12
+ LANGUAGE plpgsql
13
+ STABLE
14
+ SECURITY DEFINER
15
+ SET search_path = public
16
+ AS $$
17
+ DECLARE
18
+ v_payload jsonb := COALESCE(p_payload, '{}'::jsonb);
19
+ v_project_id uuid;
20
+ v_role text;
21
+ BEGIN
22
+ IF p_profile_id IS NULL THEN
23
+ RETURN false;
24
+ END IF;
25
+
26
+ SELECT assignment.role_code
27
+ INTO v_role
28
+ FROM public.user_role_assignments assignment
29
+ WHERE assignment.profile_id = p_profile_id
30
+ LIMIT 1;
31
+
32
+ IF p_domain = 'admin' THEN
33
+ RETURN v_role = 'admin';
34
+ END IF;
35
+
36
+ IF p_domain <> 'projects' THEN
37
+ RETURN v_role = ANY (ARRAY['staff', 'admin']);
38
+ END IF;
39
+
40
+ IF v_role = 'admin' THEN
41
+ RETURN true;
42
+ END IF;
43
+
44
+ IF jsonb_typeof(v_payload->'visible_profile_ids') = 'array'
45
+ AND EXISTS (
46
+ SELECT 1
47
+ FROM jsonb_array_elements_text(v_payload->'visible_profile_ids') AS visible(profile_id)
48
+ WHERE visible.profile_id = p_profile_id::text
49
+ ) THEN
50
+ RETURN true;
51
+ END IF;
52
+
53
+ BEGIN
54
+ v_project_id := NULLIF(v_payload->>'project_id', '')::uuid;
55
+ EXCEPTION WHEN invalid_text_representation THEN
56
+ v_project_id := NULL;
57
+ END;
58
+
59
+ IF v_project_id IS NULL AND p_entity_table = 'projects' THEN
60
+ v_project_id := p_entity_id;
61
+ END IF;
62
+ IF v_project_id IS NULL THEN
63
+ RETURN false;
64
+ END IF;
65
+
66
+ RETURN EXISTS (
67
+ SELECT 1
68
+ FROM public.projects project
69
+ WHERE project.id = v_project_id
70
+ AND project.owner_profile_id = p_profile_id
71
+ )
72
+ OR EXISTS (
73
+ SELECT 1
74
+ FROM public.project_members member
75
+ WHERE member.project_id = v_project_id
76
+ AND member.profile_id = p_profile_id
77
+ )
78
+ OR EXISTS (
79
+ SELECT 1
80
+ FROM public.projects project
81
+ JOIN public.organization_members member
82
+ ON member.organization_id = project.organization_id
83
+ WHERE project.id = v_project_id
84
+ AND member.profile_id = p_profile_id
85
+ AND member.role = 'admin'
86
+ );
87
+ END;
88
+ $$;
89
+
90
+ REVOKE ALL ON FUNCTION public.can_profile_view_app_activity_event(uuid, text, text, uuid, jsonb)
91
+ FROM PUBLIC, anon, authenticated;
92
+ GRANT EXECUTE ON FUNCTION public.can_profile_view_app_activity_event(uuid, text, text, uuid, jsonb)
93
+ TO service_role;
94
+
95
+ DROP POLICY IF EXISTS "Project members can view project activity events"
96
+ ON public.app_activity_events;
97
+ CREATE POLICY "Project members can view project activity events"
98
+ ON public.app_activity_events
99
+ FOR SELECT
100
+ TO authenticated
101
+ USING (
102
+ domain = 'projects'
103
+ AND public.can_view_app_activity_event(domain, entity_table, entity_id, payload)
104
+ );
105
+
106
+ CREATE OR REPLACE FUNCTION public.capture_removed_project_member_realtime_audience()
107
+ RETURNS trigger
108
+ LANGUAGE plpgsql
109
+ SET search_path = public
110
+ AS $$
111
+ DECLARE
112
+ v_profile_id text;
113
+ v_visible jsonb;
114
+ BEGIN
115
+ IF NEW.domain <> 'projects' OR NEW.event_type <> 'member_removed' THEN
116
+ RETURN NEW;
117
+ END IF;
118
+
119
+ v_profile_id := NULLIF(NEW.payload->>'profile_id', '');
120
+ IF v_profile_id IS NULL THEN
121
+ RETURN NEW;
122
+ END IF;
123
+
124
+ v_visible := COALESCE(NEW.payload->'visible_profile_ids', '[]'::jsonb);
125
+ IF jsonb_typeof(v_visible) <> 'array' THEN
126
+ v_visible := '[]'::jsonb;
127
+ END IF;
128
+ IF NOT v_visible @> jsonb_build_array(v_profile_id) THEN
129
+ v_visible := v_visible || jsonb_build_array(v_profile_id);
130
+ END IF;
131
+
132
+ NEW.payload := COALESCE(NEW.payload, '{}'::jsonb)
133
+ || jsonb_build_object('visible_profile_ids', v_visible);
134
+ RETURN NEW;
135
+ END;
136
+ $$;
137
+
138
+ DROP TRIGGER IF EXISTS capture_removed_project_member_realtime_audience
139
+ ON public.app_activity_events;
140
+ CREATE TRIGGER capture_removed_project_member_realtime_audience
141
+ BEFORE INSERT ON public.app_activity_events
142
+ FOR EACH ROW
143
+ EXECUTE FUNCTION public.capture_removed_project_member_realtime_audience();