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 +1 -1
- package/src/constants.mjs +10 -10
- package/src/generator.mjs +19 -10
- package/template/base/app/(shell)/layout.tsx +2 -1
- package/template/base/app/(shell)/shell-layout-client.tsx +8 -9
- package/template/base/app/api/invitations/_dependencies.ts +2 -0
- package/template/base/app/api/notifications/route.ts +1 -0
- package/template/base/config/module-toolbar-controls.tsx +15 -2
- package/template/supabase/modules/admin/migrations/20260731122500_admin_activity_visibility.sql +51 -0
- package/template/supabase/modules/core/migrations/20260731120000_core_notifications.sql +165 -0
- package/template/supabase/modules/core/migrations/20260731122000_core_realtime_activity.sql +50 -0
- package/template/supabase/modules/projects/migrations/20260731121000_project_notification_audiences.sql +81 -0
- package/template/supabase/modules/projects/migrations/20260731123000_project_realtime_visibility.sql +143 -0
package/package.json
CHANGED
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.
|
|
175
|
-
"@brightweblabs/core-auth": "^0.
|
|
176
|
-
"@brightweblabs/infra": "^0.
|
|
177
|
-
"@brightweblabs/module-admin": "^0.
|
|
178
|
-
"@brightweblabs/module-crm": "^0.
|
|
179
|
-
"@brightweblabs/module-marketing": "^0.2.
|
|
180
|
-
"@brightweblabs/module-orgs": "^0.3.
|
|
181
|
-
"@brightweblabs/module-projects": "^0.
|
|
182
|
-
"@brightweblabs/theme": "^0.
|
|
183
|
-
"@brightweblabs/ui": "^1.
|
|
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
|
|
1024
|
+
const entries = [];
|
|
1025
1025
|
|
|
1026
1026
|
if (selectedModules.includes("admin")) {
|
|
1027
1027
|
imports.push('import { AdminToolbarControls } from "@brightweblabs/module-admin/ui";');
|
|
1028
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1045
|
-
"
|
|
1046
|
-
...
|
|
1047
|
-
"
|
|
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
|
-
].
|
|
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
|
|
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,
|
|
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
|
-
|
|
5
|
-
|
|
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
|
}
|
package/template/supabase/modules/admin/migrations/20260731122500_admin_activity_visibility.sql
ADDED
|
@@ -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;
|
package/template/supabase/modules/projects/migrations/20260731123000_project_realtime_visibility.sql
ADDED
|
@@ -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();
|