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
@@ -1,5 +1,5 @@
1
1
  <svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
2
2
  <rect width="64" height="64" rx="18" fill="#13251A"/>
3
- <path d="M19 42V22H31.8C36.4 22 39.6 24.7 39.6 28.8C39.6 32.1 37.7 34.5 34.8 35.4L41.8 42H36L29.7 36H24V42H19ZM24 31.9H31.1C33.5 31.9 34.7 30.8 34.7 29C34.7 27.2 33.5 26.1 31.1 26.1H24V31.9Z" fill="#F5F4EC"/>
4
- <circle cx="46.5" cy="18.5" r="4.5" fill="#71C48B"/>
3
+ <path d="M15 44V20H28.02C32.8 20 35.76 22.42 35.76 26.24C35.76 28.58 34.62 30.28 32.58 31.18C35.22 32.02 36.84 34.04 36.84 36.86C36.84 41.36 33.42 44 28.02 44H15ZM20.36 29.58H27.08C29.08 29.58 30.24 28.62 30.24 26.96C30.24 25.3 29.08 24.36 27.08 24.36H20.36V29.58ZM20.36 39.62H27.64C29.98 39.62 31.28 38.56 31.28 36.68C31.28 34.84 29.98 33.78 27.64 33.78H20.36V39.62Z" fill="#F5F4EC"/>
4
+ <path d="M38.72 20H44.18L46.58 34.74L49.72 20H54.5L49.32 44H44.12L41.18 29.34L38.24 44H33.16L32.26 39.72H37.22L38.72 20Z" fill="#71C48B"/>
5
5
  </svg>
@@ -0,0 +1,6 @@
1
+ {
2
+ "contractVersion": 1,
3
+ "key": "admin",
4
+ "requires": { "core": ">=0.3" },
5
+ "env": []
6
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "contractVersion": 1,
3
+ "key": "crm",
4
+ "requires": { "core": ">=0.3", "admin": ">=0.3", "orgs": ">=0.1" },
5
+ "env": [],
6
+ "tokens": "./tokens.css"
7
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "contractVersion": 1,
3
+ "key": "orgs",
4
+ "requires": { "core": ">=0.3", "admin": ">=0.3" },
5
+ "env": []
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "contractVersion": 1,
3
+ "key": "projects",
4
+ "requires": { "core": ">=0.3", "admin": ">=0.3", "orgs": ">=0.1" },
5
+ "env": []
6
+ }
@@ -6,3 +6,13 @@ export const GET = createModuleRouteHandler(
6
6
  () => import("@brightweblabs/module-crm"),
7
7
  "handleCrmContactsGetRequest",
8
8
  );
9
+
10
+ export const POST = createModuleRouteHandler(
11
+ () => import("@brightweblabs/module-crm"),
12
+ "handleCrmContactsPostRequest",
13
+ );
14
+
15
+ export const PATCH = createModuleRouteHandler(
16
+ () => import("@brightweblabs/module-crm"),
17
+ "handleCrmContactsPatchRequest",
18
+ );
@@ -0,0 +1,8 @@
1
+ import { createModuleRouteHandler } from "../_shared/create-module-route-handler";
2
+
3
+ export const dynamic = "force-dynamic";
4
+
5
+ export const GET = createModuleRouteHandler(
6
+ () => import("@brightweblabs/module-crm"),
7
+ "handleCrmTimelineGetRequest",
8
+ );
@@ -0,0 +1,5 @@
1
+ import "@brightweblabs/module-crm/tokens.css";
2
+
3
+ export default function CrmLayout({ children }: Readonly<{ children: React.ReactNode }>) {
4
+ return children;
5
+ }
@@ -0,0 +1,5 @@
1
+ import { CrmDashboard } from "@brightweblabs/module-crm/ui";
2
+
3
+ export default function CrmPage() {
4
+ return <CrmDashboard />;
5
+ }
@@ -12,16 +12,22 @@
12
12
  "dependsOn": ["core"],
13
13
  "description": "RBAC, user governance, and privileged role-management behavior."
14
14
  },
15
+ "orgs": {
16
+ "label": "Organizations",
17
+ "path": "supabase/modules/orgs/migrations",
18
+ "dependsOn": ["core", "admin"],
19
+ "description": "Organizations, membership, invitations, and shared organization access helpers."
20
+ },
15
21
  "crm": {
16
22
  "label": "CRM",
17
23
  "path": "supabase/modules/crm/migrations",
18
- "dependsOn": ["core", "admin"],
19
- "description": "Organizations, CRM contacts, org membership, and invitation flows."
24
+ "dependsOn": ["core", "admin", "orgs"],
25
+ "description": "CRM contacts, status tracking, and organization integration."
20
26
  },
21
27
  "projects": {
22
28
  "label": "Projects",
23
29
  "path": "supabase/modules/projects/migrations",
24
- "dependsOn": ["core", "admin", "crm"],
30
+ "dependsOn": ["core", "admin", "orgs"],
25
31
  "description": "Projects, tasks, milestones, members, links, and project activity policies."
26
32
  }
27
33
  }
@@ -11,12 +11,14 @@
11
11
  - `organization_invitations`
12
12
  - CRM/org helper functions like `is_org_admin()` and `set_crm_status(...)`
13
13
  - CRM/org RLS and triggers
14
+ - app-owned operational contact records, not external ESP topic identifiers
14
15
 
15
16
  ## Shared boundary
16
17
 
17
18
  Some marketing data is adjacent to CRM, but the Brightweb v1 split is:
18
19
 
19
20
  - CRM owns operational contact and organization management
21
+ - CRM does not currently define newsletter/product-update subscription tables or Resend Topic mappings
20
22
  - Marketing automation remains its own future module boundary
21
23
 
22
24
  ## Dependency
@@ -1,80 +1,7 @@
1
1
  -- Brightweb CRM v1 baseline.
2
-
3
- CREATE TABLE IF NOT EXISTS public.organizations (
4
- id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
5
- name text NOT NULL,
6
- industry text,
7
- company_size text,
8
- budget_range text,
9
- website_url text,
10
- address text,
11
- tax_identifier_value text,
12
- tax_identifier_kind text,
13
- tax_identifier_country_code text,
14
- primary_contact_id uuid REFERENCES public.profiles(id) ON DELETE SET NULL,
15
- created_at timestamptz NOT NULL DEFAULT now(),
16
- CONSTRAINT organizations_tax_identifier_kind_check
17
- CHECK (
18
- tax_identifier_kind IS NULL
19
- OR tax_identifier_kind IN ('vat', 'tax', 'registration', 'other')
20
- ),
21
- CONSTRAINT organizations_tax_identifier_country_code_check
22
- CHECK (
23
- tax_identifier_country_code IS NULL
24
- OR tax_identifier_country_code ~ '^[A-Z]{2}$'
25
- )
26
- );
27
-
28
- CREATE INDEX IF NOT EXISTS idx_organizations_primary_contact_id
29
- ON public.organizations (primary_contact_id);
30
-
31
- CREATE INDEX IF NOT EXISTS idx_organizations_tax_identifier_lookup
32
- ON public.organizations (tax_identifier_country_code, tax_identifier_kind, tax_identifier_value)
33
- WHERE tax_identifier_value IS NOT NULL;
34
-
35
- CREATE TABLE IF NOT EXISTS public.organization_members (
36
- id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
37
- organization_id uuid NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
38
- profile_id uuid NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
39
- role text NOT NULL,
40
- joined_at timestamptz NOT NULL DEFAULT now(),
41
- CONSTRAINT organization_members_role_check CHECK (role IN ('admin', 'member')),
42
- CONSTRAINT organization_members_unique UNIQUE (organization_id, profile_id)
43
- );
44
-
45
- CREATE INDEX IF NOT EXISTS idx_organization_members_profile_id
46
- ON public.organization_members (profile_id);
47
-
48
- CREATE OR REPLACE FUNCTION public.is_org_admin(target_org_id uuid)
49
- RETURNS boolean
50
- LANGUAGE sql
51
- STABLE
52
- SECURITY DEFINER
53
- SET search_path = public
54
- AS $$
55
- SELECT EXISTS (
56
- SELECT 1
57
- FROM public.organization_members om
58
- WHERE om.organization_id = target_org_id
59
- AND om.profile_id = public.current_profile_id()
60
- AND om.role = 'admin'
61
- )
62
- $$;
63
-
64
- CREATE OR REPLACE FUNCTION public.is_org_member(target_org_id uuid)
65
- RETURNS boolean
66
- LANGUAGE sql
67
- STABLE
68
- SECURITY DEFINER
69
- SET search_path = public
70
- AS $$
71
- SELECT EXISTS (
72
- SELECT 1
73
- FROM public.organization_members om
74
- WHERE om.organization_id = target_org_id
75
- AND om.profile_id = public.current_profile_id()
76
- )
77
- $$;
2
+ -- Organizations install first from the orgs module. Cross-module invitation
3
+ -- linkage and primary-contact membership synchronization are added by the
4
+ -- following CRM org integration migration.
78
5
 
79
6
  CREATE TABLE IF NOT EXISTS public.crm_contacts (
80
7
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -187,131 +114,8 @@ BEGIN
187
114
  END;
188
115
  $$;
189
116
 
190
- CREATE TABLE IF NOT EXISTS public.organization_invitations (
191
- id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
192
- organization_id uuid NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
193
- invited_email text NOT NULL,
194
- role text NOT NULL CHECK (role IN ('admin', 'member')),
195
- status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'revoked', 'expired')),
196
- invited_by_profile_id uuid REFERENCES public.profiles(id) ON DELETE SET NULL,
197
- accepted_by_profile_id uuid REFERENCES public.profiles(id) ON DELETE SET NULL,
198
- accepted_contact_id uuid REFERENCES public.crm_contacts(id) ON DELETE SET NULL,
199
- accepted_at timestamptz,
200
- revoked_at timestamptz,
201
- expires_at timestamptz NOT NULL DEFAULT (now() + interval '14 days'),
202
- created_at timestamptz NOT NULL DEFAULT now(),
203
- updated_at timestamptz NOT NULL DEFAULT now()
204
- );
205
-
206
- CREATE UNIQUE INDEX IF NOT EXISTS organization_invitations_unique_org_email
207
- ON public.organization_invitations (organization_id, invited_email);
208
-
209
- CREATE INDEX IF NOT EXISTS idx_organization_invitations_org_status
210
- ON public.organization_invitations (organization_id, status);
211
-
212
- CREATE INDEX IF NOT EXISTS idx_organization_invitations_email_status
213
- ON public.organization_invitations (invited_email, status);
214
-
215
- CREATE INDEX IF NOT EXISTS idx_organization_invitations_accepted_contact_id
216
- ON public.organization_invitations (accepted_contact_id)
217
- WHERE accepted_contact_id IS NOT NULL;
218
-
219
- CREATE OR REPLACE FUNCTION public.touch_organization_invitations_updated_at()
220
- RETURNS trigger
221
- LANGUAGE plpgsql
222
- SECURITY DEFINER
223
- SET search_path = public
224
- AS $$
225
- BEGIN
226
- NEW.updated_at := now();
227
- RETURN NEW;
228
- END;
229
- $$;
230
-
231
- DROP TRIGGER IF EXISTS trg_touch_organization_invitations_updated_at ON public.organization_invitations;
232
- CREATE TRIGGER trg_touch_organization_invitations_updated_at
233
- BEFORE UPDATE ON public.organization_invitations
234
- FOR EACH ROW
235
- EXECUTE FUNCTION public.touch_organization_invitations_updated_at();
236
-
237
- CREATE OR REPLACE FUNCTION public.normalize_organization_invitation_email()
238
- RETURNS trigger
239
- LANGUAGE plpgsql
240
- SECURITY DEFINER
241
- SET search_path = public
242
- AS $$
243
- BEGIN
244
- NEW.invited_email := lower(trim(NEW.invited_email));
245
- RETURN NEW;
246
- END;
247
- $$;
248
-
249
- DROP TRIGGER IF EXISTS trg_normalize_organization_invitation_email ON public.organization_invitations;
250
- CREATE TRIGGER trg_normalize_organization_invitation_email
251
- BEFORE INSERT OR UPDATE OF invited_email ON public.organization_invitations
252
- FOR EACH ROW
253
- EXECUTE FUNCTION public.normalize_organization_invitation_email();
254
-
255
- CREATE OR REPLACE FUNCTION public.sync_org_primary_contact_admin_membership()
256
- RETURNS trigger
257
- LANGUAGE plpgsql
258
- SECURITY DEFINER
259
- SET search_path = public
260
- AS $$
261
- BEGIN
262
- IF NEW.primary_contact_id IS NOT NULL THEN
263
- INSERT INTO public.organization_members (organization_id, profile_id, role)
264
- VALUES (NEW.id, NEW.primary_contact_id, 'admin')
265
- ON CONFLICT (organization_id, profile_id)
266
- DO UPDATE SET role = 'admin';
267
- END IF;
268
-
269
- RETURN NEW;
270
- END;
271
- $$;
272
-
273
- DROP TRIGGER IF EXISTS trg_sync_org_primary_contact_admin_membership ON public.organizations;
274
- CREATE TRIGGER trg_sync_org_primary_contact_admin_membership
275
- AFTER INSERT OR UPDATE OF primary_contact_id ON public.organizations
276
- FOR EACH ROW
277
- EXECUTE FUNCTION public.sync_org_primary_contact_admin_membership();
278
-
279
- ALTER TABLE public.organizations ENABLE ROW LEVEL SECURITY;
280
- ALTER TABLE public.organization_members ENABLE ROW LEVEL SECURITY;
281
117
  ALTER TABLE public.crm_contacts ENABLE ROW LEVEL SECURITY;
282
118
  ALTER TABLE public.crm_status_log ENABLE ROW LEVEL SECURITY;
283
- ALTER TABLE public.organization_invitations ENABLE ROW LEVEL SECURITY;
284
-
285
- DROP POLICY IF EXISTS "Staff manage organizations" ON public.organizations;
286
- CREATE POLICY "Staff manage organizations"
287
- ON public.organizations
288
- FOR ALL
289
- TO authenticated
290
- USING (public.is_staff())
291
- WITH CHECK (public.is_staff());
292
-
293
- DROP POLICY IF EXISTS "Staff manage org members" ON public.organization_members;
294
- CREATE POLICY "Staff manage org members"
295
- ON public.organization_members
296
- FOR ALL
297
- TO authenticated
298
- USING (public.is_staff())
299
- WITH CHECK (public.is_staff());
300
-
301
- DROP POLICY IF EXISTS "Org admins manage org members" ON public.organization_members;
302
- CREATE POLICY "Org admins manage org members"
303
- ON public.organization_members
304
- FOR ALL
305
- TO authenticated
306
- USING (public.is_org_admin(organization_id))
307
- WITH CHECK (public.is_org_admin(organization_id));
308
-
309
- DROP POLICY IF EXISTS "Members view own org membership" ON public.organization_members;
310
- CREATE POLICY "Members view own org membership"
311
- ON public.organization_members
312
- FOR SELECT
313
- TO authenticated
314
- USING (profile_id = public.current_profile_id());
315
119
 
316
120
  DROP POLICY IF EXISTS "Staff manage crm contacts" ON public.crm_contacts;
317
121
  CREATE POLICY "Staff manage crm contacts"
@@ -328,39 +132,6 @@ CREATE POLICY "Staff view crm status log"
328
132
  TO authenticated
329
133
  USING (public.is_staff());
330
134
 
331
- DROP POLICY IF EXISTS "Staff manage organization invitations" ON public.organization_invitations;
332
- CREATE POLICY "Staff manage organization invitations"
333
- ON public.organization_invitations
334
- FOR ALL
335
- TO authenticated
336
- USING (public.is_staff())
337
- WITH CHECK (public.is_staff());
338
-
339
- DROP POLICY IF EXISTS "Org admins manage organization invitations" ON public.organization_invitations;
340
- CREATE POLICY "Org admins manage organization invitations"
341
- ON public.organization_invitations
342
- FOR ALL
343
- TO authenticated
344
- USING (public.is_org_admin(organization_id))
345
- WITH CHECK (public.is_org_admin(organization_id));
346
-
347
- DROP POLICY IF EXISTS "Invited users view own invitations" ON public.organization_invitations;
348
- CREATE POLICY "Invited users view own invitations"
349
- ON public.organization_invitations
350
- FOR SELECT
351
- TO authenticated
352
- USING (
353
- status = 'pending'
354
- AND lower(invited_email) = lower(coalesce(auth.jwt() ->> 'email', ''))
355
- );
356
-
357
- INSERT INTO public.organization_members (organization_id, profile_id, role)
358
- SELECT o.id, o.primary_contact_id, 'admin'
359
- FROM public.organizations o
360
- WHERE o.primary_contact_id IS NOT NULL
361
- ON CONFLICT (organization_id, profile_id)
362
- DO UPDATE SET role = 'admin';
363
-
364
135
  UPDATE public.crm_contacts c
365
136
  SET profile_id = p.id,
366
137
  updated_at = now()
@@ -369,24 +140,3 @@ WHERE c.profile_id IS NULL
369
140
  AND c.email IS NOT NULL
370
141
  AND p.email IS NOT NULL
371
142
  AND lower(trim(c.email)) = lower(trim(p.email));
372
-
373
- UPDATE public.organization_invitations oi
374
- SET accepted_contact_id = (
375
- SELECT c.id
376
- FROM public.crm_contacts c
377
- WHERE (
378
- oi.accepted_by_profile_id IS NOT NULL
379
- AND c.profile_id = oi.accepted_by_profile_id
380
- )
381
- OR (
382
- oi.accepted_by_profile_id IS NULL
383
- AND oi.invited_email IS NOT NULL
384
- AND c.email IS NOT NULL
385
- AND lower(trim(c.email)) = lower(trim(oi.invited_email))
386
- )
387
- ORDER BY c.updated_at DESC NULLS LAST, c.created_at DESC NULLS LAST, c.id DESC
388
- LIMIT 1
389
- ),
390
- updated_at = now()
391
- WHERE oi.accepted_contact_id IS NULL
392
- AND oi.status = 'accepted';
@@ -0,0 +1,66 @@
1
+ -- CRM integration for the independently installable Organizations module.
2
+ -- The orgs baseline keeps accepted_contact_id as a plain uuid and omits this
3
+ -- trigger so it never references CRM-owned objects during an orgs-only install.
4
+
5
+ DO $$
6
+ BEGIN
7
+ IF NOT EXISTS (
8
+ SELECT 1
9
+ FROM pg_constraint
10
+ WHERE conname = 'organization_invitations_accepted_contact_id_fkey'
11
+ AND conrelid = 'public.organization_invitations'::regclass
12
+ ) THEN
13
+ ALTER TABLE public.organization_invitations
14
+ ADD CONSTRAINT organization_invitations_accepted_contact_id_fkey
15
+ FOREIGN KEY (accepted_contact_id)
16
+ REFERENCES public.crm_contacts(id)
17
+ ON DELETE SET NULL;
18
+ END IF;
19
+ END;
20
+ $$;
21
+
22
+ CREATE OR REPLACE FUNCTION public.sync_org_primary_contact_admin_membership()
23
+ RETURNS trigger
24
+ LANGUAGE plpgsql
25
+ SECURITY DEFINER
26
+ SET search_path = public
27
+ AS $$
28
+ BEGIN
29
+ IF NEW.primary_contact_id IS NOT NULL THEN
30
+ INSERT INTO public.organization_members (organization_id, profile_id, role)
31
+ VALUES (NEW.id, NEW.primary_contact_id, 'admin')
32
+ ON CONFLICT (organization_id, profile_id)
33
+ DO UPDATE SET role = 'admin';
34
+ END IF;
35
+
36
+ RETURN NEW;
37
+ END;
38
+ $$;
39
+
40
+ DROP TRIGGER IF EXISTS trg_sync_org_primary_contact_admin_membership ON public.organizations;
41
+ CREATE TRIGGER trg_sync_org_primary_contact_admin_membership
42
+ AFTER INSERT OR UPDATE OF primary_contact_id ON public.organizations
43
+ FOR EACH ROW
44
+ EXECUTE FUNCTION public.sync_org_primary_contact_admin_membership();
45
+
46
+ UPDATE public.organization_invitations oi
47
+ SET accepted_contact_id = (
48
+ SELECT c.id
49
+ FROM public.crm_contacts c
50
+ WHERE (
51
+ oi.accepted_by_profile_id IS NOT NULL
52
+ AND c.profile_id = oi.accepted_by_profile_id
53
+ )
54
+ OR (
55
+ oi.accepted_by_profile_id IS NULL
56
+ AND oi.invited_email IS NOT NULL
57
+ AND c.email IS NOT NULL
58
+ AND lower(trim(c.email)) = lower(trim(oi.invited_email))
59
+ )
60
+ ORDER BY c.updated_at DESC NULLS LAST, c.created_at DESC NULLS LAST, c.id DESC
61
+ LIMIT 1
62
+ ),
63
+ updated_at = now()
64
+ WHERE oi.accepted_contact_id IS NULL
65
+ AND oi.status = 'accepted';
66
+
@@ -0,0 +1,19 @@
1
+ -- portal_read_indexes
2
+ -- target: crm
3
+ -- created_at: 2026-04-21T20:15:23.686Z
4
+
5
+ CREATE INDEX IF NOT EXISTS idx_crm_contacts_updated_at_desc
6
+ ON public.crm_contacts (updated_at DESC);
7
+
8
+ CREATE INDEX IF NOT EXISTS idx_crm_contacts_created_at_desc
9
+ ON public.crm_contacts (created_at DESC);
10
+
11
+ CREATE INDEX IF NOT EXISTS idx_crm_contacts_status
12
+ ON public.crm_contacts (status);
13
+
14
+ CREATE INDEX IF NOT EXISTS idx_crm_contacts_source
15
+ ON public.crm_contacts (source)
16
+ WHERE source IS NOT NULL;
17
+
18
+ CREATE INDEX IF NOT EXISTS idx_crm_status_log_changed_at_desc
19
+ ON public.crm_status_log (changed_at DESC);
@@ -0,0 +1,4 @@
1
+ # Organizations database module
2
+
3
+ Foundation schema for organizations, memberships, invitations, and organization access helpers. CRM-specific foreign keys and trigger integration are installed by the dependent CRM module.
4
+