create-bw-app 0.10.0 → 0.11.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.
Files changed (45) hide show
  1. package/README.md +16 -1
  2. package/bin/bw.mjs +8 -0
  3. package/package.json +5 -2
  4. package/src/add.mjs +100 -0
  5. package/src/adopt.mjs +176 -0
  6. package/src/app-manifest.mjs +250 -0
  7. package/src/bw.mjs +53 -0
  8. package/src/constants.mjs +22 -11
  9. package/src/diff.mjs +85 -0
  10. package/src/doctor.mjs +99 -0
  11. package/src/generator.mjs +68 -12
  12. package/src/migrations.mjs +92 -0
  13. package/src/remove.mjs +100 -0
  14. package/src/scaffold.mjs +82 -0
  15. package/src/update.mjs +51 -3
  16. package/src/upgrade.mjs +73 -0
  17. package/template/base/AGENTS.md +2 -0
  18. package/template/base/app/globals.css +30 -14
  19. package/template/base/app/page.tsx +1 -0
  20. package/template/base/config/bootstrap.ts +1 -1
  21. package/template/base/config/brand.ts +0 -2
  22. package/template/base/config/modules.ts +2 -2
  23. package/template/base/config/shell.overrides.ts +16 -0
  24. package/template/base/docs/ai/README.md +6 -3
  25. package/template/base/docs/ai/examples.md +4 -2
  26. package/template/base/public/brand/logo-dark.svg +2 -2
  27. package/template/base/public/brand/logo-light.svg +2 -2
  28. package/template/base/public/brand/logo-mark.svg +2 -2
  29. package/template/module-manifests/admin/brightweb.module.json +6 -0
  30. package/template/module-manifests/crm/brightweb.module.json +7 -0
  31. package/template/module-manifests/orgs/brightweb.module.json +6 -0
  32. package/template/module-manifests/projects/brightweb.module.json +6 -0
  33. package/template/modules/crm/app/api/crm/contacts/route.ts +10 -0
  34. package/template/modules/crm/app/api/crm/timeline/route.ts +8 -0
  35. package/template/modules/crm/app/crm/layout.tsx +5 -0
  36. package/template/modules/crm/app/crm/page.tsx +5 -0
  37. package/template/supabase/module-registry.json +9 -3
  38. package/template/supabase/modules/crm/README.md +2 -0
  39. package/template/supabase/modules/crm/migrations/20260316092000_crm_v1.sql +3 -253
  40. package/template/supabase/modules/crm/migrations/20260316092010_crm_org_integration.sql +66 -0
  41. package/template/supabase/modules/crm/migrations/20260421201523_portal_read_indexes.sql +19 -0
  42. package/template/supabase/modules/orgs/README.md +4 -0
  43. package/template/supabase/modules/orgs/migrations/20260316091500_orgs_v1.sql +216 -0
  44. package/template/supabase/modules/projects/migrations/20260421201528_portal_read_indexes.sql +6 -0
  45. package/template/modules/crm/app/playground/crm/page.tsx +0 -103
@@ -0,0 +1,216 @@
1
+ -- Brightweb Organizations v1 baseline.
2
+ -- accepted_contact_id intentionally remains a plain nullable uuid here so this
3
+ -- foundation module can install without CRM. CRM adds the foreign key and the
4
+ -- primary-contact membership trigger in its org integration migration.
5
+
6
+ CREATE TABLE IF NOT EXISTS public.organizations (
7
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
8
+ name text NOT NULL,
9
+ industry text,
10
+ company_size text,
11
+ budget_range text,
12
+ website_url text,
13
+ address text,
14
+ tax_identifier_value text,
15
+ tax_identifier_kind text,
16
+ tax_identifier_country_code text,
17
+ primary_contact_id uuid REFERENCES public.profiles(id) ON DELETE SET NULL,
18
+ created_at timestamptz NOT NULL DEFAULT now(),
19
+ CONSTRAINT organizations_tax_identifier_kind_check
20
+ CHECK (
21
+ tax_identifier_kind IS NULL
22
+ OR tax_identifier_kind IN ('vat', 'tax', 'registration', 'other')
23
+ ),
24
+ CONSTRAINT organizations_tax_identifier_country_code_check
25
+ CHECK (
26
+ tax_identifier_country_code IS NULL
27
+ OR tax_identifier_country_code ~ '^[A-Z]{2}$'
28
+ )
29
+ );
30
+
31
+ CREATE INDEX IF NOT EXISTS idx_organizations_primary_contact_id
32
+ ON public.organizations (primary_contact_id);
33
+
34
+ CREATE INDEX IF NOT EXISTS idx_organizations_tax_identifier_lookup
35
+ ON public.organizations (tax_identifier_country_code, tax_identifier_kind, tax_identifier_value)
36
+ WHERE tax_identifier_value IS NOT NULL;
37
+
38
+ CREATE INDEX IF NOT EXISTS idx_organizations_created_at_desc
39
+ ON public.organizations (created_at DESC);
40
+
41
+ CREATE TABLE IF NOT EXISTS public.organization_members (
42
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
43
+ organization_id uuid NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
44
+ profile_id uuid NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
45
+ role text NOT NULL,
46
+ joined_at timestamptz NOT NULL DEFAULT now(),
47
+ CONSTRAINT organization_members_role_check CHECK (role IN ('admin', 'member')),
48
+ CONSTRAINT organization_members_unique UNIQUE (organization_id, profile_id)
49
+ );
50
+
51
+ CREATE INDEX IF NOT EXISTS idx_organization_members_profile_id
52
+ ON public.organization_members (profile_id);
53
+
54
+ CREATE OR REPLACE FUNCTION public.is_org_admin(target_org_id uuid)
55
+ RETURNS boolean
56
+ LANGUAGE sql
57
+ STABLE
58
+ SECURITY DEFINER
59
+ SET search_path = public
60
+ AS $$
61
+ SELECT EXISTS (
62
+ SELECT 1
63
+ FROM public.organization_members om
64
+ WHERE om.organization_id = target_org_id
65
+ AND om.profile_id = public.current_profile_id()
66
+ AND om.role = 'admin'
67
+ )
68
+ $$;
69
+
70
+ CREATE OR REPLACE FUNCTION public.is_org_member(target_org_id uuid)
71
+ RETURNS boolean
72
+ LANGUAGE sql
73
+ STABLE
74
+ SECURITY DEFINER
75
+ SET search_path = public
76
+ AS $$
77
+ SELECT EXISTS (
78
+ SELECT 1
79
+ FROM public.organization_members om
80
+ WHERE om.organization_id = target_org_id
81
+ AND om.profile_id = public.current_profile_id()
82
+ )
83
+ $$;
84
+
85
+ CREATE TABLE IF NOT EXISTS public.organization_invitations (
86
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
87
+ organization_id uuid NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
88
+ invited_email text NOT NULL,
89
+ role text NOT NULL CHECK (role IN ('admin', 'member')),
90
+ status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'revoked', 'expired')),
91
+ invited_by_profile_id uuid REFERENCES public.profiles(id) ON DELETE SET NULL,
92
+ accepted_by_profile_id uuid REFERENCES public.profiles(id) ON DELETE SET NULL,
93
+ accepted_contact_id uuid,
94
+ accepted_at timestamptz,
95
+ revoked_at timestamptz,
96
+ expires_at timestamptz NOT NULL DEFAULT (now() + interval '14 days'),
97
+ created_at timestamptz NOT NULL DEFAULT now(),
98
+ updated_at timestamptz NOT NULL DEFAULT now()
99
+ );
100
+
101
+ CREATE UNIQUE INDEX IF NOT EXISTS organization_invitations_unique_org_email
102
+ ON public.organization_invitations (organization_id, invited_email);
103
+
104
+ CREATE INDEX IF NOT EXISTS idx_organization_invitations_org_status
105
+ ON public.organization_invitations (organization_id, status);
106
+
107
+ CREATE INDEX IF NOT EXISTS idx_organization_invitations_email_status
108
+ ON public.organization_invitations (invited_email, status);
109
+
110
+ CREATE INDEX IF NOT EXISTS idx_organization_invitations_accepted_contact_id
111
+ ON public.organization_invitations (accepted_contact_id)
112
+ WHERE accepted_contact_id IS NOT NULL;
113
+
114
+ CREATE OR REPLACE FUNCTION public.touch_organization_invitations_updated_at()
115
+ RETURNS trigger
116
+ LANGUAGE plpgsql
117
+ SECURITY DEFINER
118
+ SET search_path = public
119
+ AS $$
120
+ BEGIN
121
+ NEW.updated_at := now();
122
+ RETURN NEW;
123
+ END;
124
+ $$;
125
+
126
+ DROP TRIGGER IF EXISTS trg_touch_organization_invitations_updated_at ON public.organization_invitations;
127
+ CREATE TRIGGER trg_touch_organization_invitations_updated_at
128
+ BEFORE UPDATE ON public.organization_invitations
129
+ FOR EACH ROW
130
+ EXECUTE FUNCTION public.touch_organization_invitations_updated_at();
131
+
132
+ CREATE OR REPLACE FUNCTION public.normalize_organization_invitation_email()
133
+ RETURNS trigger
134
+ LANGUAGE plpgsql
135
+ SECURITY DEFINER
136
+ SET search_path = public
137
+ AS $$
138
+ BEGIN
139
+ NEW.invited_email := lower(trim(NEW.invited_email));
140
+ RETURN NEW;
141
+ END;
142
+ $$;
143
+
144
+ DROP TRIGGER IF EXISTS trg_normalize_organization_invitation_email ON public.organization_invitations;
145
+ CREATE TRIGGER trg_normalize_organization_invitation_email
146
+ BEFORE INSERT OR UPDATE OF invited_email ON public.organization_invitations
147
+ FOR EACH ROW
148
+ EXECUTE FUNCTION public.normalize_organization_invitation_email();
149
+
150
+ ALTER TABLE public.organizations ENABLE ROW LEVEL SECURITY;
151
+ ALTER TABLE public.organization_members ENABLE ROW LEVEL SECURITY;
152
+ ALTER TABLE public.organization_invitations ENABLE ROW LEVEL SECURITY;
153
+
154
+ DROP POLICY IF EXISTS "Staff manage organizations" ON public.organizations;
155
+ CREATE POLICY "Staff manage organizations"
156
+ ON public.organizations
157
+ FOR ALL
158
+ TO authenticated
159
+ USING (public.is_staff())
160
+ WITH CHECK (public.is_staff());
161
+
162
+ DROP POLICY IF EXISTS "Staff manage org members" ON public.organization_members;
163
+ CREATE POLICY "Staff manage org members"
164
+ ON public.organization_members
165
+ FOR ALL
166
+ TO authenticated
167
+ USING (public.is_staff())
168
+ WITH CHECK (public.is_staff());
169
+
170
+ DROP POLICY IF EXISTS "Org admins manage org members" ON public.organization_members;
171
+ CREATE POLICY "Org admins manage org members"
172
+ ON public.organization_members
173
+ FOR ALL
174
+ TO authenticated
175
+ USING (public.is_org_admin(organization_id))
176
+ WITH CHECK (public.is_org_admin(organization_id));
177
+
178
+ DROP POLICY IF EXISTS "Members view own org membership" ON public.organization_members;
179
+ CREATE POLICY "Members view own org membership"
180
+ ON public.organization_members
181
+ FOR SELECT
182
+ TO authenticated
183
+ USING (profile_id = public.current_profile_id());
184
+
185
+ DROP POLICY IF EXISTS "Staff manage organization invitations" ON public.organization_invitations;
186
+ CREATE POLICY "Staff manage organization invitations"
187
+ ON public.organization_invitations
188
+ FOR ALL
189
+ TO authenticated
190
+ USING (public.is_staff())
191
+ WITH CHECK (public.is_staff());
192
+
193
+ DROP POLICY IF EXISTS "Org admins manage organization invitations" ON public.organization_invitations;
194
+ CREATE POLICY "Org admins manage organization invitations"
195
+ ON public.organization_invitations
196
+ FOR ALL
197
+ TO authenticated
198
+ USING (public.is_org_admin(organization_id))
199
+ WITH CHECK (public.is_org_admin(organization_id));
200
+
201
+ DROP POLICY IF EXISTS "Invited users view own invitations" ON public.organization_invitations;
202
+ CREATE POLICY "Invited users view own invitations"
203
+ ON public.organization_invitations
204
+ FOR SELECT
205
+ TO authenticated
206
+ USING (
207
+ status = 'pending'
208
+ AND lower(invited_email) = lower(coalesce(auth.jwt() ->> 'email', ''))
209
+ );
210
+
211
+ INSERT INTO public.organization_members (organization_id, profile_id, role)
212
+ SELECT o.id, o.primary_contact_id, 'admin'
213
+ FROM public.organizations o
214
+ WHERE o.primary_contact_id IS NOT NULL
215
+ ON CONFLICT (organization_id, profile_id)
216
+ DO UPDATE SET role = 'admin';
@@ -0,0 +1,6 @@
1
+ -- portal_read_indexes
2
+ -- target: projects
3
+ -- created_at: 2026-04-21T20:15:28.609Z
4
+
5
+ CREATE INDEX IF NOT EXISTS idx_project_tasks_status
6
+ ON public.project_tasks (status);
@@ -1,103 +0,0 @@
1
- export const dynamic = "force-dynamic";
2
-
3
- function formatError(error: unknown) {
4
- if (error instanceof Error) {
5
- return error.message;
6
- }
7
-
8
- return "Unknown error";
9
- }
10
-
11
- export default async function CrmPlaygroundPage() {
12
- try {
13
- const { getCrmDashboardData } = await import("@brightweblabs/module-crm");
14
- const data = await getCrmDashboardData();
15
-
16
- return (
17
- <>
18
- <article className="panel">
19
- <div className="panel-inner">
20
- <p className="eyebrow">CRM Module</p>
21
- <h1>CRM server/data playground</h1>
22
- <p className="muted">
23
- This route calls the external `@brightweblabs/module-crm` package directly and shows a thin data summary.
24
- </p>
25
- </div>
26
- </article>
27
-
28
- <article className="grid">
29
- <div className="panel">
30
- <div className="panel-inner">
31
- <p className="status ok">Connected</p>
32
- <h2>Snapshot</h2>
33
- <ul className="list">
34
- <li>Total contacts: {data.stats.total}</li>
35
- <li>Organizations loaded: {data.organizations.length}</li>
36
- <li>Timeline events: {data.statusLog.length}</li>
37
- <li>Owner options: {data.ownerOptions.length}</li>
38
- </ul>
39
- </div>
40
- </div>
41
-
42
- <div className="panel">
43
- <div className="panel-inner">
44
- <h2>Statuses</h2>
45
- <div className="code-box">
46
- <pre>{JSON.stringify(data.stats.byStatus, null, 2)}</pre>
47
- </div>
48
- </div>
49
- </div>
50
- </article>
51
-
52
- <article className="panel">
53
- <div className="panel-inner">
54
- <h2>First records</h2>
55
- <div className="code-box">
56
- <pre>
57
- {JSON.stringify(
58
- {
59
- organizations: data.organizations.slice(0, 2),
60
- contacts: data.contacts.slice(0, 3),
61
- statusLog: data.statusLog.slice(0, 3),
62
- },
63
- null,
64
- 2,
65
- )}
66
- </pre>
67
- </div>
68
- </div>
69
- </article>
70
- </>
71
- );
72
- } catch (error) {
73
- return (
74
- <>
75
- <article className="panel">
76
- <div className="panel-inner">
77
- <p className="eyebrow">CRM Module</p>
78
- <h1>CRM server/data playground</h1>
79
- <p className="muted">
80
- The package is wired, but this sandbox still needs valid environment variables and database access.
81
- </p>
82
- </div>
83
- </article>
84
-
85
- <article className="panel">
86
- <div className="panel-inner stack">
87
- <p className="status warn">Configuration required</p>
88
- <div className="result-box">
89
- <strong>Current error</strong>
90
- <p>{formatError(error)}</p>
91
- </div>
92
- <ul className="list">
93
- <li>Set `NEXT_PUBLIC_APP_URL`.</li>
94
- <li>Set `NEXT_PUBLIC_SUPABASE_URL`.</li>
95
- <li>Set `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY`.</li>
96
- <li>If you need CRM writes or email flows, also set the server-only keys in the platform repo.</li>
97
- </ul>
98
- </div>
99
- </article>
100
- </>
101
- );
102
- }
103
- }