create-bw-app 0.23.2 → 0.24.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 +9 -9
- package/src/generator.mjs +1 -0
- package/src/upgrade.mjs +2 -3
- package/template/base/app/api/notifications/route.ts +1 -1
- package/template/base/app/api/organizations/[id]/route.ts +5 -0
- package/template/modules/marketing/app/api/marketing/_handlers.ts +6 -0
- package/template/modules/marketing/app/api/marketing/campaigns/[id]/recipients/[recipientId]/route.ts +2 -0
- package/template/modules/marketing/app/api/marketing/topics/[id]/route.ts +1 -1
- package/template/modules/projects/app/(shell)/projetos/projetos-server-mounts.tsx +13 -5
- package/template/supabase/modules/core/migrations/20260801120000_core_notification_dismissals.sql +78 -0
- package/template/supabase/modules/marketing/migrations/20260801121000_marketing_deletion_safety.sql +107 -0
- package/template/supabase/modules/projects/migrations/20260801122000_project_member_sync.sql +45 -0
package/package.json
CHANGED
package/src/constants.mjs
CHANGED
|
@@ -173,16 +173,16 @@ export const PLATFORM_STARTER_FILES = [
|
|
|
173
173
|
];
|
|
174
174
|
|
|
175
175
|
export const APP_DEPENDENCY_DEFAULTS = {
|
|
176
|
-
"@brightweblabs/app-shell": "^0.
|
|
177
|
-
"@brightweblabs/core-auth": "^0.
|
|
176
|
+
"@brightweblabs/app-shell": "^0.11.0",
|
|
177
|
+
"@brightweblabs/core-auth": "^0.10.0",
|
|
178
178
|
"@brightweblabs/infra": "^0.7.0",
|
|
179
|
-
"@brightweblabs/module-admin": "^0.8.
|
|
180
|
-
"@brightweblabs/module-crm": "^0.
|
|
181
|
-
"@brightweblabs/module-marketing": "^0.
|
|
182
|
-
"@brightweblabs/module-orgs": "^0.
|
|
183
|
-
"@brightweblabs/module-projects": "^0.
|
|
184
|
-
"@brightweblabs/theme": "^0.7.
|
|
185
|
-
"@brightweblabs/ui": "^1.4.
|
|
179
|
+
"@brightweblabs/module-admin": "^0.8.5",
|
|
180
|
+
"@brightweblabs/module-crm": "^0.14.0",
|
|
181
|
+
"@brightweblabs/module-marketing": "^0.4.0",
|
|
182
|
+
"@brightweblabs/module-orgs": "^0.4.0",
|
|
183
|
+
"@brightweblabs/module-projects": "^0.13.0",
|
|
184
|
+
"@brightweblabs/theme": "^0.7.2",
|
|
185
|
+
"@brightweblabs/ui": "^1.4.4",
|
|
186
186
|
"geist": "1.7.2",
|
|
187
187
|
"lucide-react": "^1.8.0",
|
|
188
188
|
"next": "^16.0.0",
|
package/src/generator.mjs
CHANGED
|
@@ -1206,6 +1206,7 @@ export function createOptionalModuleRouteFiles(selectedModules) {
|
|
|
1206
1206
|
], orgsEnabled),
|
|
1207
1207
|
"app/api/organizations/[id]/route.ts": createOrganizationRoute([
|
|
1208
1208
|
{ method: "PATCH", handler: "handleOrganizationPatchRequest", context: true },
|
|
1209
|
+
{ method: "DELETE", handler: "handleOrganizationDeleteRequest", context: true },
|
|
1209
1210
|
], orgsEnabled),
|
|
1210
1211
|
"app/api/organizations/[id]/invitations/route.ts": createOrganizationRoute([
|
|
1211
1212
|
{ method: "GET", handler: "handleOrganizationInvitationsGetRequest", context: true },
|
package/src/upgrade.mjs
CHANGED
|
@@ -59,9 +59,8 @@ export async function upgradeBrightwebApp(moduleKey, argvOptions = {}, runtimeOp
|
|
|
59
59
|
}
|
|
60
60
|
await applyMigrationWrites(migrationPlan.writes);
|
|
61
61
|
appManifest.migrationCursor = migrationPlan.nextCursor;
|
|
62
|
-
for (const
|
|
63
|
-
|
|
64
|
-
if (key && appManifest.modules[key]) appManifest.modules[key].version = cleanVersion(update.to) || appManifest.modules[key].version;
|
|
62
|
+
for (const [key, entry] of Object.entries(appManifest.modules)) {
|
|
63
|
+
if (catalog[key]?.packageRoot) entry.version = catalog[key].version;
|
|
65
64
|
}
|
|
66
65
|
for (const relativePath of plan.starterFilesToRefresh || []) {
|
|
67
66
|
const targetPath = resolveSafeRelativePath(targetDir, relativePath, "Manifest scaffold file path");
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { handleNotificationsGetRequest as GET, handleNotificationsPostRequest as POST } from "@brightweblabs/core-auth/notifications";
|
|
1
|
+
export { handleNotificationsDeleteRequest as DELETE, handleNotificationsGetRequest as GET, handleNotificationsPostRequest as POST } from "@brightweblabs/core-auth/notifications";
|
|
@@ -4,3 +4,8 @@ export async function PATCH(request: Request, context: { params: Promise<{ id: s
|
|
|
4
4
|
const { handleOrganizationPatchRequest } = await import("@brightweblabs/module-orgs");
|
|
5
5
|
return handleOrganizationPatchRequest(request, context);
|
|
6
6
|
}
|
|
7
|
+
|
|
8
|
+
export async function DELETE(request: Request, context: { params: Promise<{ id: string }> }) {
|
|
9
|
+
const { handleOrganizationDeleteRequest } = await import("@brightweblabs/module-orgs");
|
|
10
|
+
return handleOrganizationDeleteRequest(request, context);
|
|
11
|
+
}
|
|
@@ -67,6 +67,9 @@ export function marketingTopicsOrderPost(request: Request) {
|
|
|
67
67
|
export function marketingTopicPatch(request: Request, context: IdRouteContext) {
|
|
68
68
|
return handlers.topicPatch(request, context);
|
|
69
69
|
}
|
|
70
|
+
export function marketingTopicDelete(request: Request, context: IdRouteContext) {
|
|
71
|
+
return handlers.topicDelete(request, context);
|
|
72
|
+
}
|
|
70
73
|
export function marketingWebhookPost(request: Request) {
|
|
71
74
|
return handlers.webhookPost(request);
|
|
72
75
|
}
|
|
@@ -100,6 +103,9 @@ export function marketingCampaignCancelPost(request: Request, context: IdRouteCo
|
|
|
100
103
|
export function marketingCampaignRecipientsGet(request: Request, context: IdRouteContext) {
|
|
101
104
|
return handlers.recipientsGet(request, context);
|
|
102
105
|
}
|
|
106
|
+
export function marketingCampaignRecipientDelete(request: Request, context: { params: Promise<{ id: string; recipientId: string }> }) {
|
|
107
|
+
return handlers.recipientDelete(request, context);
|
|
108
|
+
}
|
|
103
109
|
export function marketingCampaignRetryPost(request: Request, context: IdRouteContext) {
|
|
104
110
|
return handlers.retryPost(request, context);
|
|
105
111
|
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { marketingTopicPatch as PATCH } from "../../_handlers";
|
|
1
|
+
export { marketingTopicDelete as DELETE, marketingTopicPatch as PATCH } from "../../_handlers";
|
|
2
2
|
export const dynamic = "force-dynamic";
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { requireServerPageAccess } from "@brightweblabs/core-auth/server";
|
|
2
2
|
import {
|
|
3
3
|
getProjectDashboard,
|
|
4
|
+
getProjectAccess,
|
|
4
5
|
getProjectsPortfolioPageData,
|
|
5
6
|
} from "@brightweblabs/module-projects";
|
|
6
7
|
import {
|
|
@@ -37,18 +38,25 @@ export async function ProjectsServerMount() {
|
|
|
37
38
|
|
|
38
39
|
async function getInitialProject(params: Promise<{ projectId: string }>) {
|
|
39
40
|
const { projectId } = await params;
|
|
40
|
-
const { supabase } = await requireServerPageAccess();
|
|
41
|
-
|
|
41
|
+
const { supabase, profileId, role } = await requireServerPageAccess();
|
|
42
|
+
const [initialData, access] = await Promise.all([
|
|
43
|
+
getProjectDashboard(supabase, projectId),
|
|
44
|
+
getProjectAccess(supabase, projectId, profileId, role),
|
|
45
|
+
]);
|
|
46
|
+
return { initialData, access };
|
|
42
47
|
}
|
|
43
48
|
|
|
44
49
|
export async function ProjectDetailServerMount({ params }: { params: Promise<{ projectId: string }> }) {
|
|
45
|
-
|
|
50
|
+
const { initialData, access } = await getInitialProject(params);
|
|
51
|
+
return <ProjectDetailPageLiveMount initialData={initialData} projectRole={access.projectRole} permissions={access.permissions} navigation={navigation} />;
|
|
46
52
|
}
|
|
47
53
|
|
|
48
54
|
export async function ProjectBoardServerMount({ params }: { params: Promise<{ projectId: string }> }) {
|
|
49
|
-
|
|
55
|
+
const { initialData, access } = await getInitialProject(params);
|
|
56
|
+
return <ProjectBoardPageLiveMount initialData={initialData} projectRole={access.projectRole} permissions={access.permissions} navigation={navigation} />;
|
|
50
57
|
}
|
|
51
58
|
|
|
52
59
|
export async function ProjectTasksServerMount({ params }: { params: Promise<{ projectId: string }> }) {
|
|
53
|
-
|
|
60
|
+
const { initialData, access } = await getInitialProject(params);
|
|
61
|
+
return <ProjectTasksPageLiveMount initialData={initialData} projectRole={access.projectRole} permissions={access.permissions} navigation={navigation} />;
|
|
54
62
|
}
|
package/template/supabase/modules/core/migrations/20260801120000_core_notification_dismissals.sql
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
-- Per-profile notification dismissal without deleting shared audit events.
|
|
2
|
+
|
|
3
|
+
CREATE TABLE IF NOT EXISTS public.user_notification_dismissals (
|
|
4
|
+
profile_id uuid NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
|
5
|
+
activity_event_id uuid NOT NULL REFERENCES public.app_activity_events(id) ON DELETE CASCADE,
|
|
6
|
+
dismissed_at timestamptz NOT NULL DEFAULT now(),
|
|
7
|
+
PRIMARY KEY (profile_id, activity_event_id)
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
ALTER TABLE public.user_notification_dismissals ENABLE ROW LEVEL SECURITY;
|
|
11
|
+
REVOKE ALL ON TABLE public.user_notification_dismissals FROM PUBLIC, anon, authenticated;
|
|
12
|
+
GRANT ALL ON TABLE public.user_notification_dismissals TO service_role;
|
|
13
|
+
|
|
14
|
+
CREATE OR REPLACE FUNCTION public.current_notification_summary(p_profile_id uuid)
|
|
15
|
+
RETURNS TABLE(seen_at timestamptz, unread_count bigint)
|
|
16
|
+
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public
|
|
17
|
+
AS $$
|
|
18
|
+
WITH notification_state AS (
|
|
19
|
+
SELECT state.alerts_seen_at FROM public.user_notification_state state
|
|
20
|
+
WHERE state.profile_id = p_profile_id LIMIT 1
|
|
21
|
+
)
|
|
22
|
+
SELECT (SELECT alerts_seen_at FROM notification_state), count(event.id)
|
|
23
|
+
FROM public.app_activity_events event
|
|
24
|
+
WHERE public.can_profile_view_app_activity_event(p_profile_id, event.domain, event.entity_table, event.entity_id, event.payload)
|
|
25
|
+
AND ((SELECT alerts_seen_at FROM notification_state) IS NULL OR event.created_at > (SELECT alerts_seen_at FROM notification_state))
|
|
26
|
+
AND (event.actor_profile_id IS NULL OR event.actor_profile_id <> p_profile_id)
|
|
27
|
+
AND NOT EXISTS (
|
|
28
|
+
SELECT 1 FROM public.user_notification_dismissals dismissal
|
|
29
|
+
WHERE dismissal.profile_id = p_profile_id AND dismissal.activity_event_id = event.id
|
|
30
|
+
)
|
|
31
|
+
$$;
|
|
32
|
+
|
|
33
|
+
DROP FUNCTION IF EXISTS public.current_notification_items(uuid, integer);
|
|
34
|
+
CREATE FUNCTION public.current_notification_items(p_profile_id uuid, p_limit integer DEFAULT 8)
|
|
35
|
+
RETURNS TABLE(id uuid, created_at timestamptz, domain text, event_type text, entity_table text, entity_id uuid, actor_profile_id uuid, summary text, payload jsonb)
|
|
36
|
+
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public
|
|
37
|
+
AS $$
|
|
38
|
+
SELECT event.id, event.created_at, event.domain, event.event_type, event.entity_table,
|
|
39
|
+
event.entity_id, event.actor_profile_id, event.summary, COALESCE(event.payload, '{}'::jsonb)
|
|
40
|
+
FROM public.app_activity_events event
|
|
41
|
+
WHERE public.can_profile_view_app_activity_event(p_profile_id, event.domain, event.entity_table, event.entity_id, event.payload)
|
|
42
|
+
AND (event.actor_profile_id IS NULL OR event.actor_profile_id <> p_profile_id)
|
|
43
|
+
AND NOT EXISTS (
|
|
44
|
+
SELECT 1 FROM public.user_notification_dismissals dismissal
|
|
45
|
+
WHERE dismissal.profile_id = p_profile_id AND dismissal.activity_event_id = event.id
|
|
46
|
+
)
|
|
47
|
+
ORDER BY event.created_at DESC
|
|
48
|
+
LIMIT LEAST(GREATEST(COALESCE(p_limit, 8), 0), 50)
|
|
49
|
+
$$;
|
|
50
|
+
|
|
51
|
+
CREATE OR REPLACE FUNCTION public.dismiss_current_notifications(p_profile_id uuid, p_activity_event_id uuid DEFAULT NULL)
|
|
52
|
+
RETURNS integer
|
|
53
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
|
54
|
+
AS $$
|
|
55
|
+
DECLARE v_count integer;
|
|
56
|
+
BEGIN
|
|
57
|
+
IF p_activity_event_id IS NULL THEN
|
|
58
|
+
RETURN 0;
|
|
59
|
+
END IF;
|
|
60
|
+
|
|
61
|
+
INSERT INTO public.user_notification_dismissals(profile_id, activity_event_id)
|
|
62
|
+
SELECT p_profile_id, event.id
|
|
63
|
+
FROM public.app_activity_events event
|
|
64
|
+
WHERE event.id = p_activity_event_id
|
|
65
|
+
AND public.can_profile_view_app_activity_event(p_profile_id, event.domain, event.entity_table, event.entity_id, event.payload)
|
|
66
|
+
AND (event.actor_profile_id IS NULL OR event.actor_profile_id <> p_profile_id)
|
|
67
|
+
ON CONFLICT (profile_id, activity_event_id) DO NOTHING;
|
|
68
|
+
GET DIAGNOSTICS v_count = ROW_COUNT;
|
|
69
|
+
RETURN v_count;
|
|
70
|
+
END;
|
|
71
|
+
$$;
|
|
72
|
+
|
|
73
|
+
REVOKE ALL ON FUNCTION public.current_notification_summary(uuid) FROM PUBLIC, anon, authenticated;
|
|
74
|
+
REVOKE ALL ON FUNCTION public.current_notification_items(uuid, integer) FROM PUBLIC, anon, authenticated;
|
|
75
|
+
REVOKE ALL ON FUNCTION public.dismiss_current_notifications(uuid, uuid) FROM PUBLIC, anon, authenticated;
|
|
76
|
+
GRANT EXECUTE ON FUNCTION public.current_notification_summary(uuid) TO service_role;
|
|
77
|
+
GRANT EXECUTE ON FUNCTION public.current_notification_items(uuid, integer) TO service_role;
|
|
78
|
+
GRANT EXECUTE ON FUNCTION public.dismiss_current_notifications(uuid, uuid) TO service_role;
|
package/template/supabase/modules/marketing/migrations/20260801121000_marketing_deletion_safety.sql
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
-- Serialize destructive campaign operations with delivery state transitions.
|
|
2
|
+
|
|
3
|
+
CREATE OR REPLACE FUNCTION public.maintain_marketing_campaign_recipient_count()
|
|
4
|
+
RETURNS trigger
|
|
5
|
+
LANGUAGE plpgsql
|
|
6
|
+
SECURITY DEFINER
|
|
7
|
+
SET search_path = public
|
|
8
|
+
AS $$
|
|
9
|
+
BEGIN
|
|
10
|
+
IF TG_OP = 'INSERT' THEN
|
|
11
|
+
UPDATE public.marketing_campaigns
|
|
12
|
+
SET total_recipients = total_recipients + 1
|
|
13
|
+
WHERE id = NEW.campaign_id;
|
|
14
|
+
RETURN NEW;
|
|
15
|
+
END IF;
|
|
16
|
+
|
|
17
|
+
UPDATE public.marketing_campaigns
|
|
18
|
+
SET total_recipients = GREATEST(0, total_recipients - 1)
|
|
19
|
+
WHERE id = OLD.campaign_id;
|
|
20
|
+
RETURN OLD;
|
|
21
|
+
END;
|
|
22
|
+
$$;
|
|
23
|
+
|
|
24
|
+
UPDATE public.marketing_campaigns campaign
|
|
25
|
+
SET total_recipients = (
|
|
26
|
+
SELECT count(*)::integer
|
|
27
|
+
FROM public.marketing_campaign_recipients recipient
|
|
28
|
+
WHERE recipient.campaign_id = campaign.id
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
DROP TRIGGER IF EXISTS trg_maintain_marketing_campaign_recipient_count
|
|
32
|
+
ON public.marketing_campaign_recipients;
|
|
33
|
+
CREATE TRIGGER trg_maintain_marketing_campaign_recipient_count
|
|
34
|
+
AFTER INSERT OR DELETE ON public.marketing_campaign_recipients
|
|
35
|
+
FOR EACH ROW
|
|
36
|
+
EXECUTE FUNCTION public.maintain_marketing_campaign_recipient_count();
|
|
37
|
+
|
|
38
|
+
CREATE OR REPLACE FUNCTION public.delete_marketing_campaign_safely(p_campaign_id uuid)
|
|
39
|
+
RETURNS text
|
|
40
|
+
LANGUAGE plpgsql
|
|
41
|
+
SECURITY INVOKER
|
|
42
|
+
SET search_path = public
|
|
43
|
+
AS $$
|
|
44
|
+
DECLARE
|
|
45
|
+
v_status text;
|
|
46
|
+
BEGIN
|
|
47
|
+
SELECT status INTO v_status
|
|
48
|
+
FROM public.marketing_campaigns
|
|
49
|
+
WHERE id = p_campaign_id
|
|
50
|
+
FOR UPDATE;
|
|
51
|
+
|
|
52
|
+
IF NOT FOUND THEN
|
|
53
|
+
RETURN 'not_found';
|
|
54
|
+
END IF;
|
|
55
|
+
IF v_status NOT IN ('draft', 'canceled') THEN
|
|
56
|
+
RETURN 'invalid_status';
|
|
57
|
+
END IF;
|
|
58
|
+
|
|
59
|
+
DELETE FROM public.marketing_campaigns WHERE id = p_campaign_id;
|
|
60
|
+
RETURN 'deleted';
|
|
61
|
+
END;
|
|
62
|
+
$$;
|
|
63
|
+
|
|
64
|
+
CREATE OR REPLACE FUNCTION public.delete_marketing_campaign_recipient_safely(
|
|
65
|
+
p_campaign_id uuid,
|
|
66
|
+
p_recipient_id uuid
|
|
67
|
+
)
|
|
68
|
+
RETURNS text
|
|
69
|
+
LANGUAGE plpgsql
|
|
70
|
+
SECURITY INVOKER
|
|
71
|
+
SET search_path = public
|
|
72
|
+
AS $$
|
|
73
|
+
DECLARE
|
|
74
|
+
v_campaign_status text;
|
|
75
|
+
v_deleted_id uuid;
|
|
76
|
+
BEGIN
|
|
77
|
+
SELECT status INTO v_campaign_status
|
|
78
|
+
FROM public.marketing_campaigns
|
|
79
|
+
WHERE id = p_campaign_id
|
|
80
|
+
FOR UPDATE;
|
|
81
|
+
|
|
82
|
+
IF NOT FOUND THEN
|
|
83
|
+
RETURN 'campaign_not_found';
|
|
84
|
+
END IF;
|
|
85
|
+
IF v_campaign_status NOT IN ('draft', 'scheduled', 'canceled') THEN
|
|
86
|
+
RETURN 'campaign_locked';
|
|
87
|
+
END IF;
|
|
88
|
+
|
|
89
|
+
DELETE FROM public.marketing_campaign_recipients
|
|
90
|
+
WHERE id = p_recipient_id
|
|
91
|
+
AND campaign_id = p_campaign_id
|
|
92
|
+
AND status IN ('queued', 'suppressed', 'skipped')
|
|
93
|
+
RETURNING id INTO v_deleted_id;
|
|
94
|
+
|
|
95
|
+
IF v_deleted_id IS NULL THEN
|
|
96
|
+
RETURN 'recipient_not_deletable';
|
|
97
|
+
END IF;
|
|
98
|
+
|
|
99
|
+
RETURN 'deleted';
|
|
100
|
+
END;
|
|
101
|
+
$$;
|
|
102
|
+
|
|
103
|
+
REVOKE ALL ON FUNCTION public.maintain_marketing_campaign_recipient_count() FROM PUBLIC, anon, authenticated;
|
|
104
|
+
REVOKE ALL ON FUNCTION public.delete_marketing_campaign_safely(uuid) FROM PUBLIC, anon;
|
|
105
|
+
REVOKE ALL ON FUNCTION public.delete_marketing_campaign_recipient_safely(uuid, uuid) FROM PUBLIC, anon;
|
|
106
|
+
GRANT EXECUTE ON FUNCTION public.delete_marketing_campaign_safely(uuid) TO authenticated, service_role;
|
|
107
|
+
GRANT EXECUTE ON FUNCTION public.delete_marketing_campaign_recipient_safely(uuid, uuid) TO authenticated, service_role;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
-- Apply exact project membership and owner changes in one serialized transaction.
|
|
2
|
+
|
|
3
|
+
CREATE OR REPLACE FUNCTION public.sync_project_members_exact(
|
|
4
|
+
p_project_id uuid,
|
|
5
|
+
p_members jsonb,
|
|
6
|
+
p_owner_profile_id uuid DEFAULT NULL
|
|
7
|
+
)
|
|
8
|
+
RETURNS void
|
|
9
|
+
LANGUAGE plpgsql
|
|
10
|
+
SECURITY INVOKER
|
|
11
|
+
SET search_path = public
|
|
12
|
+
AS $$
|
|
13
|
+
BEGIN
|
|
14
|
+
IF jsonb_typeof(COALESCE(p_members, '[]'::jsonb)) <> 'array' THEN
|
|
15
|
+
RAISE EXCEPTION 'Project members must be a JSON array.' USING ERRCODE = '22023';
|
|
16
|
+
END IF;
|
|
17
|
+
|
|
18
|
+
PERFORM 1 FROM public.projects WHERE id = p_project_id FOR UPDATE;
|
|
19
|
+
IF NOT FOUND THEN
|
|
20
|
+
RAISE EXCEPTION 'Projeto não encontrado.' USING ERRCODE = 'P0002';
|
|
21
|
+
END IF;
|
|
22
|
+
|
|
23
|
+
INSERT INTO public.project_members(project_id, profile_id, role)
|
|
24
|
+
SELECT p_project_id, member.profile_id, member.role
|
|
25
|
+
FROM jsonb_to_recordset(COALESCE(p_members, '[]'::jsonb))
|
|
26
|
+
AS member(profile_id uuid, role text)
|
|
27
|
+
ON CONFLICT (project_id, profile_id) DO UPDATE SET role = EXCLUDED.role;
|
|
28
|
+
|
|
29
|
+
DELETE FROM public.project_members existing
|
|
30
|
+
WHERE existing.project_id = p_project_id
|
|
31
|
+
AND NOT EXISTS (
|
|
32
|
+
SELECT 1
|
|
33
|
+
FROM jsonb_to_recordset(COALESCE(p_members, '[]'::jsonb))
|
|
34
|
+
AS member(profile_id uuid, role text)
|
|
35
|
+
WHERE member.profile_id = existing.profile_id
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
UPDATE public.projects
|
|
39
|
+
SET owner_profile_id = p_owner_profile_id
|
|
40
|
+
WHERE id = p_project_id;
|
|
41
|
+
END;
|
|
42
|
+
$$;
|
|
43
|
+
|
|
44
|
+
REVOKE ALL ON FUNCTION public.sync_project_members_exact(uuid, jsonb, uuid) FROM PUBLIC, anon;
|
|
45
|
+
GRANT EXECUTE ON FUNCTION public.sync_project_members_exact(uuid, jsonb, uuid) TO authenticated, service_role;
|