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.
- package/README.md +16 -1
- package/bin/bw.mjs +8 -0
- package/package.json +5 -2
- package/src/add.mjs +100 -0
- package/src/adopt.mjs +176 -0
- package/src/app-manifest.mjs +250 -0
- package/src/bw.mjs +53 -0
- package/src/constants.mjs +22 -11
- package/src/diff.mjs +85 -0
- package/src/doctor.mjs +99 -0
- package/src/generator.mjs +68 -12
- package/src/migrations.mjs +92 -0
- package/src/remove.mjs +100 -0
- package/src/scaffold.mjs +82 -0
- package/src/update.mjs +51 -3
- package/src/upgrade.mjs +73 -0
- package/template/base/AGENTS.md +2 -0
- package/template/base/app/globals.css +30 -14
- package/template/base/app/page.tsx +1 -0
- package/template/base/config/bootstrap.ts +1 -1
- package/template/base/config/brand.ts +0 -2
- package/template/base/config/modules.ts +2 -2
- package/template/base/config/shell.overrides.ts +16 -0
- package/template/base/docs/ai/README.md +6 -3
- package/template/base/docs/ai/examples.md +4 -2
- package/template/base/public/brand/logo-dark.svg +2 -2
- package/template/base/public/brand/logo-light.svg +2 -2
- package/template/base/public/brand/logo-mark.svg +2 -2
- package/template/module-manifests/admin/brightweb.module.json +6 -0
- package/template/module-manifests/crm/brightweb.module.json +7 -0
- package/template/module-manifests/orgs/brightweb.module.json +6 -0
- package/template/module-manifests/projects/brightweb.module.json +6 -0
- package/template/modules/crm/app/api/crm/contacts/route.ts +10 -0
- package/template/modules/crm/app/api/crm/timeline/route.ts +8 -0
- package/template/modules/crm/app/crm/layout.tsx +5 -0
- package/template/modules/crm/app/crm/page.tsx +5 -0
- package/template/supabase/module-registry.json +9 -3
- package/template/supabase/modules/crm/README.md +2 -0
- package/template/supabase/modules/crm/migrations/20260316092000_crm_v1.sql +3 -253
- package/template/supabase/modules/crm/migrations/20260316092010_crm_org_integration.sql +66 -0
- package/template/supabase/modules/crm/migrations/20260421201523_portal_read_indexes.sql +19 -0
- package/template/supabase/modules/orgs/README.md +4 -0
- package/template/supabase/modules/orgs/migrations/20260316091500_orgs_v1.sql +216 -0
- package/template/supabase/modules/projects/migrations/20260421201528_portal_read_indexes.sql +6 -0
- 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';
|
|
@@ -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
|
-
}
|