create-bw-app 0.9.4 → 0.9.6

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.
Files changed (33) hide show
  1. package/README.md +5 -0
  2. package/package.json +1 -1
  3. package/src/cli.mjs +5 -0
  4. package/src/constants.mjs +1 -0
  5. package/src/generator.mjs +102 -36
  6. package/src/update.mjs +67 -1
  7. package/template/base/AGENTS.md +1 -0
  8. package/template/base/app/playground/auth/page.tsx +1 -1
  9. package/template/base/app/preview/app-shell/page.tsx +1 -1
  10. package/template/base/{app/preview → components}/app-shell-preview.tsx +2 -2
  11. package/template/base/docs/ai/README.md +3 -0
  12. package/template/base/docs/ai/examples.md +2 -0
  13. package/template/modules/crm/app/api/crm/_shared/create-module-route-handler.ts +13 -0
  14. package/template/modules/crm/app/api/crm/contacts/route.ts +6 -4
  15. package/template/modules/crm/app/api/crm/organizations/route.ts +6 -4
  16. package/template/modules/crm/app/api/crm/owners/route.ts +6 -4
  17. package/template/modules/crm/app/api/crm/stats/route.ts +6 -4
  18. package/template/supabase/README.md +74 -0
  19. package/template/supabase/clients/README.md +19 -0
  20. package/template/supabase/module-registry.json +28 -0
  21. package/template/supabase/modules/admin/README.md +18 -0
  22. package/template/supabase/modules/admin/migrations/.gitkeep +1 -0
  23. package/template/supabase/modules/admin/migrations/20260316091000_admin_v1.sql +317 -0
  24. package/template/supabase/modules/core/README.md +24 -0
  25. package/template/supabase/modules/core/migrations/.gitkeep +1 -0
  26. package/template/supabase/modules/core/migrations/20260316090000_core_v1.sql +497 -0
  27. package/template/supabase/modules/crm/README.md +27 -0
  28. package/template/supabase/modules/crm/migrations/.gitkeep +1 -0
  29. package/template/supabase/modules/crm/migrations/20260316092000_crm_v1.sql +392 -0
  30. package/template/supabase/modules/projects/README.md +22 -0
  31. package/template/supabase/modules/projects/migrations/.gitkeep +1 -0
  32. package/template/supabase/modules/projects/migrations/20260316093000_projects_v1.sql +1120 -0
  33. /package/template/base/{app/playground/auth → components}/auth-playground.tsx +0 -0
@@ -0,0 +1,497 @@
1
+ -- Brightweb core v1 baseline.
2
+
3
+ CREATE TABLE IF NOT EXISTS public.profiles (
4
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
5
+ email text NOT NULL,
6
+ created_at timestamptz NOT NULL DEFAULT now(),
7
+ first_name text,
8
+ last_name text,
9
+ user_id uuid UNIQUE REFERENCES auth.users(id) ON DELETE CASCADE,
10
+ updated_at timestamptz NOT NULL DEFAULT now(),
11
+ CONSTRAINT profiles_email_key UNIQUE (email)
12
+ );
13
+
14
+ CREATE INDEX IF NOT EXISTS idx_profiles_user_id
15
+ ON public.profiles (user_id);
16
+
17
+ CREATE OR REPLACE FUNCTION public.touch_profiles_updated_at()
18
+ RETURNS trigger
19
+ LANGUAGE plpgsql
20
+ SECURITY DEFINER
21
+ SET search_path = public
22
+ AS $$
23
+ BEGIN
24
+ NEW.updated_at := now();
25
+ RETURN NEW;
26
+ END;
27
+ $$;
28
+
29
+ DROP TRIGGER IF EXISTS trg_touch_profiles_updated_at ON public.profiles;
30
+ CREATE TRIGGER trg_touch_profiles_updated_at
31
+ BEFORE UPDATE ON public.profiles
32
+ FOR EACH ROW
33
+ EXECUTE FUNCTION public.touch_profiles_updated_at();
34
+
35
+ CREATE OR REPLACE FUNCTION public.current_profile_id()
36
+ RETURNS uuid
37
+ LANGUAGE sql
38
+ STABLE
39
+ SECURITY DEFINER
40
+ SET search_path = public
41
+ AS $$
42
+ SELECT p.id
43
+ FROM public.profiles p
44
+ WHERE p.user_id = auth.uid()
45
+ LIMIT 1
46
+ $$;
47
+
48
+ CREATE OR REPLACE FUNCTION public.current_global_role()
49
+ RETURNS text
50
+ LANGUAGE sql
51
+ STABLE
52
+ SECURITY DEFINER
53
+ SET search_path = public
54
+ AS $$
55
+ SELECT NULL::text
56
+ $$;
57
+
58
+ CREATE OR REPLACE FUNCTION public.has_role(target_role text)
59
+ RETURNS boolean
60
+ LANGUAGE sql
61
+ STABLE
62
+ SECURITY DEFINER
63
+ SET search_path = public
64
+ AS $$
65
+ SELECT false
66
+ $$;
67
+
68
+ CREATE OR REPLACE FUNCTION public.is_staff()
69
+ RETURNS boolean
70
+ LANGUAGE sql
71
+ STABLE
72
+ SECURITY DEFINER
73
+ SET search_path = public
74
+ AS $$
75
+ SELECT false
76
+ $$;
77
+
78
+ CREATE OR REPLACE FUNCTION public.is_admin()
79
+ RETURNS boolean
80
+ LANGUAGE sql
81
+ STABLE
82
+ SECURITY DEFINER
83
+ SET search_path = public
84
+ AS $$
85
+ SELECT false
86
+ $$;
87
+
88
+ ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
89
+
90
+ DROP POLICY IF EXISTS "Authenticated users can insert own profile" ON public.profiles;
91
+ CREATE POLICY "Authenticated users can insert own profile"
92
+ ON public.profiles
93
+ FOR INSERT
94
+ TO authenticated
95
+ WITH CHECK (auth.uid() = user_id);
96
+
97
+ DROP POLICY IF EXISTS "Authenticated users can update own profile" ON public.profiles;
98
+ CREATE POLICY "Authenticated users can update own profile"
99
+ ON public.profiles
100
+ FOR UPDATE
101
+ TO authenticated
102
+ USING (auth.uid() = user_id)
103
+ WITH CHECK (auth.uid() = user_id);
104
+
105
+ DROP POLICY IF EXISTS "Authenticated users can view own profile" ON public.profiles;
106
+ CREATE POLICY "Authenticated users can view own profile"
107
+ ON public.profiles
108
+ FOR SELECT
109
+ TO authenticated
110
+ USING (auth.uid() = user_id);
111
+
112
+ DROP POLICY IF EXISTS "Service role full access to profiles" ON public.profiles;
113
+ CREATE POLICY "Service role full access to profiles"
114
+ ON public.profiles
115
+ TO service_role
116
+ USING (true)
117
+ WITH CHECK (true);
118
+
119
+ CREATE OR REPLACE FUNCTION public.sync_profile_from_auth_identity(
120
+ p_user_id uuid,
121
+ p_email text,
122
+ p_metadata jsonb DEFAULT '{}'::jsonb
123
+ )
124
+ RETURNS void
125
+ LANGUAGE plpgsql
126
+ SECURITY DEFINER
127
+ SET search_path = public, auth
128
+ AS $$
129
+ DECLARE
130
+ v_email text;
131
+ v_first_name text;
132
+ v_last_name text;
133
+ v_full_name text;
134
+ v_name_parts text[];
135
+ BEGIN
136
+ v_email := NULLIF(lower(trim(COALESCE(p_email, ''))), '');
137
+
138
+ IF v_email IS NULL THEN
139
+ RETURN;
140
+ END IF;
141
+
142
+ v_first_name := NULLIF(trim(COALESCE(p_metadata ->> 'first_name', '')), '');
143
+ v_last_name := NULLIF(trim(COALESCE(p_metadata ->> 'last_name', '')), '');
144
+ v_full_name := NULLIF(
145
+ trim(
146
+ COALESCE(
147
+ p_metadata ->> 'full_name',
148
+ p_metadata ->> 'name',
149
+ p_metadata ->> 'display_name',
150
+ ''
151
+ )
152
+ ),
153
+ ''
154
+ );
155
+
156
+ IF v_first_name IS NULL AND v_full_name IS NOT NULL THEN
157
+ v_name_parts := regexp_split_to_array(v_full_name, '\s+');
158
+ IF array_length(v_name_parts, 1) >= 1 THEN
159
+ v_first_name := v_name_parts[1];
160
+ END IF;
161
+ IF array_length(v_name_parts, 1) >= 2 THEN
162
+ v_last_name := array_to_string(v_name_parts[2:array_length(v_name_parts, 1)], ' ');
163
+ END IF;
164
+ END IF;
165
+
166
+ INSERT INTO public.profiles (
167
+ user_id,
168
+ email,
169
+ first_name,
170
+ last_name,
171
+ created_at,
172
+ updated_at
173
+ )
174
+ VALUES (
175
+ p_user_id,
176
+ v_email,
177
+ v_first_name,
178
+ v_last_name,
179
+ now(),
180
+ now()
181
+ )
182
+ ON CONFLICT (user_id) DO UPDATE
183
+ SET
184
+ email = EXCLUDED.email,
185
+ first_name = COALESCE(NULLIF(trim(public.profiles.first_name), ''), EXCLUDED.first_name),
186
+ last_name = COALESCE(NULLIF(trim(public.profiles.last_name), ''), EXCLUDED.last_name),
187
+ updated_at = now();
188
+ END;
189
+ $$;
190
+
191
+ CREATE OR REPLACE FUNCTION public.handle_auth_user_profile_sync()
192
+ RETURNS trigger
193
+ LANGUAGE plpgsql
194
+ SECURITY DEFINER
195
+ SET search_path = public, auth
196
+ AS $$
197
+ BEGIN
198
+ BEGIN
199
+ PERFORM public.sync_profile_from_auth_identity(NEW.id, NEW.email, NEW.raw_user_meta_data);
200
+ EXCEPTION
201
+ WHEN OTHERS THEN
202
+ RAISE WARNING 'handle_auth_user_profile_sync failed for user %: %', NEW.id, SQLERRM;
203
+ END;
204
+
205
+ RETURN NEW;
206
+ END;
207
+ $$;
208
+
209
+ DROP TRIGGER IF EXISTS on_auth_user_profile_sync ON auth.users;
210
+ CREATE TRIGGER on_auth_user_profile_sync
211
+ AFTER INSERT OR UPDATE OF email, raw_user_meta_data
212
+ ON auth.users
213
+ FOR EACH ROW
214
+ EXECUTE FUNCTION public.handle_auth_user_profile_sync();
215
+
216
+ DO $$
217
+ DECLARE
218
+ auth_user record;
219
+ BEGIN
220
+ FOR auth_user IN
221
+ SELECT id, email, raw_user_meta_data
222
+ FROM auth.users
223
+ LOOP
224
+ PERFORM public.sync_profile_from_auth_identity(auth_user.id, auth_user.email, auth_user.raw_user_meta_data);
225
+ END LOOP;
226
+ END;
227
+ $$;
228
+
229
+ CREATE TABLE IF NOT EXISTS public.user_preferences (
230
+ profile_id uuid PRIMARY KEY REFERENCES public.profiles(id) ON DELETE CASCADE,
231
+ preferred_language text NOT NULL DEFAULT 'pt-PT',
232
+ created_at timestamptz NOT NULL DEFAULT now(),
233
+ updated_at timestamptz NOT NULL DEFAULT now(),
234
+ CONSTRAINT user_preferences_preferred_language_check
235
+ CHECK (preferred_language IN ('pt-PT', 'en'))
236
+ );
237
+
238
+ CREATE OR REPLACE FUNCTION public.touch_user_preferences_updated_at()
239
+ RETURNS trigger
240
+ LANGUAGE plpgsql
241
+ SECURITY DEFINER
242
+ SET search_path = public
243
+ AS $$
244
+ BEGIN
245
+ NEW.updated_at := now();
246
+ RETURN NEW;
247
+ END;
248
+ $$;
249
+
250
+ DROP TRIGGER IF EXISTS trg_touch_user_preferences_updated_at ON public.user_preferences;
251
+ CREATE TRIGGER trg_touch_user_preferences_updated_at
252
+ BEFORE UPDATE ON public.user_preferences
253
+ FOR EACH ROW
254
+ EXECUTE FUNCTION public.touch_user_preferences_updated_at();
255
+
256
+ ALTER TABLE public.user_preferences ENABLE ROW LEVEL SECURITY;
257
+
258
+ DROP POLICY IF EXISTS "Users view own preferences" ON public.user_preferences;
259
+ CREATE POLICY "Users view own preferences"
260
+ ON public.user_preferences
261
+ FOR SELECT
262
+ TO authenticated
263
+ USING (
264
+ profile_id = public.current_profile_id()
265
+ OR public.is_staff()
266
+ );
267
+
268
+ DROP POLICY IF EXISTS "Users insert own preferences" ON public.user_preferences;
269
+ CREATE POLICY "Users insert own preferences"
270
+ ON public.user_preferences
271
+ FOR INSERT
272
+ TO authenticated
273
+ WITH CHECK (
274
+ profile_id = public.current_profile_id()
275
+ OR public.is_staff()
276
+ );
277
+
278
+ DROP POLICY IF EXISTS "Users update own preferences" ON public.user_preferences;
279
+ CREATE POLICY "Users update own preferences"
280
+ ON public.user_preferences
281
+ FOR UPDATE
282
+ TO authenticated
283
+ USING (
284
+ profile_id = public.current_profile_id()
285
+ OR public.is_staff()
286
+ )
287
+ WITH CHECK (
288
+ profile_id = public.current_profile_id()
289
+ OR public.is_staff()
290
+ );
291
+
292
+ INSERT INTO public.user_preferences (profile_id)
293
+ SELECT p.id
294
+ FROM public.profiles p
295
+ ON CONFLICT (profile_id) DO NOTHING;
296
+
297
+ CREATE TABLE IF NOT EXISTS public.user_notification_state (
298
+ profile_id uuid PRIMARY KEY REFERENCES public.profiles(id) ON DELETE CASCADE,
299
+ alerts_seen_at timestamptz,
300
+ created_at timestamptz NOT NULL DEFAULT now(),
301
+ updated_at timestamptz NOT NULL DEFAULT now()
302
+ );
303
+
304
+ CREATE OR REPLACE FUNCTION public.touch_user_notification_state_updated_at()
305
+ RETURNS trigger
306
+ LANGUAGE plpgsql
307
+ SECURITY DEFINER
308
+ SET search_path = public
309
+ AS $$
310
+ BEGIN
311
+ NEW.updated_at := now();
312
+ RETURN NEW;
313
+ END;
314
+ $$;
315
+
316
+ DROP TRIGGER IF EXISTS trg_touch_user_notification_state_updated_at ON public.user_notification_state;
317
+ CREATE TRIGGER trg_touch_user_notification_state_updated_at
318
+ BEFORE UPDATE ON public.user_notification_state
319
+ FOR EACH ROW
320
+ EXECUTE FUNCTION public.touch_user_notification_state_updated_at();
321
+
322
+ ALTER TABLE public.user_notification_state ENABLE ROW LEVEL SECURITY;
323
+
324
+ DROP POLICY IF EXISTS "Users view own notification state" ON public.user_notification_state;
325
+ CREATE POLICY "Users view own notification state"
326
+ ON public.user_notification_state
327
+ FOR SELECT
328
+ TO authenticated
329
+ USING (profile_id = public.current_profile_id());
330
+
331
+ DROP POLICY IF EXISTS "Users insert own notification state" ON public.user_notification_state;
332
+ CREATE POLICY "Users insert own notification state"
333
+ ON public.user_notification_state
334
+ FOR INSERT
335
+ TO authenticated
336
+ WITH CHECK (profile_id = public.current_profile_id());
337
+
338
+ DROP POLICY IF EXISTS "Users update own notification state" ON public.user_notification_state;
339
+ CREATE POLICY "Users update own notification state"
340
+ ON public.user_notification_state
341
+ FOR UPDATE
342
+ TO authenticated
343
+ USING (profile_id = public.current_profile_id())
344
+ WITH CHECK (profile_id = public.current_profile_id());
345
+
346
+ INSERT INTO public.user_notification_state (profile_id)
347
+ SELECT p.id
348
+ FROM public.profiles p
349
+ ON CONFLICT (profile_id) DO NOTHING;
350
+
351
+ CREATE TABLE IF NOT EXISTS public.app_activity_events (
352
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
353
+ created_at timestamptz NOT NULL DEFAULT now(),
354
+ domain text NOT NULL,
355
+ event_type text NOT NULL,
356
+ entity_table text NOT NULL,
357
+ entity_id uuid,
358
+ actor_profile_id uuid REFERENCES public.profiles(id) ON DELETE SET NULL,
359
+ summary text NOT NULL,
360
+ payload jsonb NOT NULL DEFAULT '{}'::jsonb
361
+ );
362
+
363
+ CREATE INDEX IF NOT EXISTS idx_app_activity_events_created_at_desc
364
+ ON public.app_activity_events (created_at DESC);
365
+
366
+ CREATE INDEX IF NOT EXISTS idx_app_activity_events_domain_created_at_desc
367
+ ON public.app_activity_events (domain, created_at DESC);
368
+
369
+ CREATE INDEX IF NOT EXISTS idx_app_activity_events_entity
370
+ ON public.app_activity_events (entity_table, entity_id);
371
+
372
+ CREATE INDEX IF NOT EXISTS idx_app_activity_events_actor_profile
373
+ ON public.app_activity_events (actor_profile_id);
374
+
375
+ ALTER TABLE public.app_activity_events ENABLE ROW LEVEL SECURITY;
376
+
377
+ DROP POLICY IF EXISTS "Staff can view app activity events" ON public.app_activity_events;
378
+ CREATE POLICY "Staff can view app activity events"
379
+ ON public.app_activity_events
380
+ FOR SELECT
381
+ TO authenticated
382
+ USING (public.is_staff());
383
+
384
+ CREATE OR REPLACE FUNCTION public.log_app_activity_event(
385
+ p_domain text,
386
+ p_event_type text,
387
+ p_entity_table text,
388
+ p_entity_id uuid,
389
+ p_summary text,
390
+ p_payload jsonb DEFAULT '{}'::jsonb,
391
+ p_actor_profile_id uuid DEFAULT NULL
392
+ )
393
+ RETURNS void
394
+ LANGUAGE plpgsql
395
+ SECURITY DEFINER
396
+ SET search_path = public
397
+ AS $$
398
+ DECLARE
399
+ v_actor_profile_id uuid;
400
+ BEGIN
401
+ v_actor_profile_id := COALESCE(p_actor_profile_id, public.current_profile_id());
402
+
403
+ INSERT INTO public.app_activity_events (
404
+ domain,
405
+ event_type,
406
+ entity_table,
407
+ entity_id,
408
+ actor_profile_id,
409
+ summary,
410
+ payload
411
+ )
412
+ VALUES (
413
+ p_domain,
414
+ p_event_type,
415
+ p_entity_table,
416
+ p_entity_id,
417
+ v_actor_profile_id,
418
+ p_summary,
419
+ COALESCE(p_payload, '{}'::jsonb)
420
+ );
421
+ END;
422
+ $$;
423
+
424
+ REVOKE ALL ON FUNCTION public.log_app_activity_event(text, text, text, uuid, text, jsonb, uuid)
425
+ FROM PUBLIC;
426
+ REVOKE ALL ON FUNCTION public.log_app_activity_event(text, text, text, uuid, text, jsonb, uuid)
427
+ FROM anon;
428
+ REVOKE ALL ON FUNCTION public.log_app_activity_event(text, text, text, uuid, text, jsonb, uuid)
429
+ FROM authenticated;
430
+ GRANT EXECUTE ON FUNCTION public.log_app_activity_event(text, text, text, uuid, text, jsonb, uuid)
431
+ TO service_role;
432
+
433
+ CREATE TABLE IF NOT EXISTS public.rate_limit_counters (
434
+ key text PRIMARY KEY,
435
+ window_started_at timestamptz NOT NULL,
436
+ count integer NOT NULL DEFAULT 1 CHECK (count >= 0),
437
+ updated_at timestamptz NOT NULL DEFAULT timezone('utc', now()),
438
+ expires_at timestamptz NOT NULL
439
+ );
440
+
441
+ CREATE INDEX IF NOT EXISTS idx_rate_limit_counters_expires_at
442
+ ON public.rate_limit_counters (expires_at);
443
+
444
+ REVOKE ALL ON public.rate_limit_counters FROM anon, authenticated;
445
+
446
+ CREATE OR REPLACE FUNCTION public.check_rate_limit(
447
+ p_key text,
448
+ p_window_seconds integer,
449
+ p_max_requests integer
450
+ )
451
+ RETURNS jsonb
452
+ LANGUAGE plpgsql
453
+ SECURITY DEFINER
454
+ SET search_path = public
455
+ AS $$
456
+ DECLARE
457
+ v_now timestamptz := timezone('utc', now());
458
+ v_window_seconds integer := GREATEST(1, p_window_seconds);
459
+ v_max_requests integer := GREATEST(1, p_max_requests);
460
+ v_window_start_epoch bigint;
461
+ v_window_start timestamptz;
462
+ v_window_interval interval;
463
+ v_count integer;
464
+ v_reset_at timestamptz;
465
+ v_allowed boolean;
466
+ BEGIN
467
+ DELETE FROM public.rate_limit_counters
468
+ WHERE expires_at < v_now;
469
+
470
+ v_window_start_epoch := FLOOR(EXTRACT(EPOCH FROM v_now) / v_window_seconds) * v_window_seconds;
471
+ v_window_start := to_timestamp(v_window_start_epoch);
472
+ v_window_interval := make_interval(secs => v_window_seconds);
473
+
474
+ INSERT INTO public.rate_limit_counters (key, window_started_at, count, updated_at, expires_at)
475
+ VALUES (p_key, v_window_start, 1, v_now, v_window_start + v_window_interval)
476
+ ON CONFLICT (key) DO UPDATE
477
+ SET count = CASE
478
+ WHEN public.rate_limit_counters.window_started_at = EXCLUDED.window_started_at
479
+ THEN public.rate_limit_counters.count + 1
480
+ ELSE 1
481
+ END,
482
+ window_started_at = EXCLUDED.window_started_at,
483
+ updated_at = v_now,
484
+ expires_at = EXCLUDED.expires_at
485
+ RETURNING count, (window_started_at + v_window_interval) INTO v_count, v_reset_at;
486
+
487
+ v_allowed := v_count <= v_max_requests;
488
+
489
+ RETURN jsonb_build_object(
490
+ 'allowed', v_allowed,
491
+ 'remaining', GREATEST(v_max_requests - v_count, 0),
492
+ 'reset_at', v_reset_at
493
+ );
494
+ END;
495
+ $$;
496
+
497
+ GRANT EXECUTE ON FUNCTION public.check_rate_limit(text, integer, integer) TO anon, authenticated, service_role;
@@ -0,0 +1,27 @@
1
+ # CRM Migrations
2
+
3
+ `crm` owns organization, contact, and marketing-adjacent operational data.
4
+
5
+ ## Owns
6
+
7
+ - `organizations`
8
+ - `crm_contacts`
9
+ - `crm_status_log`
10
+ - `organization_members`
11
+ - `organization_invitations`
12
+ - CRM/org helper functions like `is_org_admin()` and `set_crm_status(...)`
13
+ - CRM/org RLS and triggers
14
+
15
+ ## Shared boundary
16
+
17
+ Some marketing data is adjacent to CRM, but the Brightweb v1 split is:
18
+
19
+ - CRM owns operational contact and organization management
20
+ - Marketing automation remains its own future module boundary
21
+
22
+ ## Dependency
23
+
24
+ Depends on:
25
+
26
+ - `core`
27
+ - `admin` when staff/admin role helpers are referenced