create-bw-app 0.9.5 → 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.
@@ -0,0 +1,392 @@
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
+ $$;
78
+
79
+ CREATE TABLE IF NOT EXISTS public.crm_contacts (
80
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
81
+ organization_id uuid REFERENCES public.organizations(id) ON DELETE SET NULL,
82
+ profile_id uuid REFERENCES public.profiles(id) ON DELETE SET NULL,
83
+ owner_id uuid REFERENCES public.profiles(id) ON DELETE SET NULL,
84
+ first_name text,
85
+ last_name text,
86
+ email text,
87
+ phone text,
88
+ status text NOT NULL DEFAULT 'lead',
89
+ source text,
90
+ created_at timestamptz NOT NULL DEFAULT now(),
91
+ updated_at timestamptz NOT NULL DEFAULT now(),
92
+ CONSTRAINT crm_contacts_status_check CHECK (status IN ('lead', 'qualified', 'proposal', 'won', 'lost'))
93
+ );
94
+
95
+ CREATE INDEX IF NOT EXISTS idx_crm_contacts_organization_id
96
+ ON public.crm_contacts (organization_id);
97
+
98
+ CREATE INDEX IF NOT EXISTS idx_crm_contacts_owner_id
99
+ ON public.crm_contacts (owner_id);
100
+
101
+ CREATE INDEX IF NOT EXISTS idx_crm_contacts_profile_id
102
+ ON public.crm_contacts (profile_id)
103
+ WHERE profile_id IS NOT NULL;
104
+
105
+ CREATE UNIQUE INDEX IF NOT EXISTS crm_contacts_email_unique
106
+ ON public.crm_contacts (email)
107
+ WHERE email IS NOT NULL;
108
+
109
+ CREATE UNIQUE INDEX IF NOT EXISTS crm_contacts_profile_id_unique
110
+ ON public.crm_contacts (profile_id)
111
+ WHERE profile_id IS NOT NULL;
112
+
113
+ CREATE OR REPLACE FUNCTION public.set_crm_contacts_updated_at()
114
+ RETURNS trigger
115
+ LANGUAGE plpgsql
116
+ SECURITY DEFINER
117
+ SET search_path = public
118
+ AS $$
119
+ BEGIN
120
+ NEW.updated_at := now();
121
+ RETURN NEW;
122
+ END;
123
+ $$;
124
+
125
+ DROP TRIGGER IF EXISTS set_crm_contacts_updated_at ON public.crm_contacts;
126
+ CREATE TRIGGER set_crm_contacts_updated_at
127
+ BEFORE UPDATE ON public.crm_contacts
128
+ FOR EACH ROW
129
+ EXECUTE FUNCTION public.set_crm_contacts_updated_at();
130
+
131
+ CREATE TABLE IF NOT EXISTS public.crm_status_log (
132
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
133
+ contact_id uuid NOT NULL REFERENCES public.crm_contacts(id) ON DELETE CASCADE,
134
+ previous_status text,
135
+ new_status text NOT NULL,
136
+ reason text,
137
+ changed_by_user_id uuid,
138
+ changed_at timestamptz NOT NULL DEFAULT now()
139
+ );
140
+
141
+ CREATE INDEX IF NOT EXISTS idx_crm_status_log_profile_id
142
+ ON public.crm_status_log (contact_id);
143
+
144
+ CREATE OR REPLACE FUNCTION public.set_crm_status(
145
+ p_contact_id uuid,
146
+ p_new_status text,
147
+ p_reason text DEFAULT NULL
148
+ )
149
+ RETURNS void
150
+ LANGUAGE plpgsql
151
+ SECURITY DEFINER
152
+ SET search_path = public
153
+ AS $$
154
+ DECLARE
155
+ v_current text;
156
+ BEGIN
157
+ SELECT status INTO v_current
158
+ FROM public.crm_contacts
159
+ WHERE id = p_contact_id;
160
+
161
+ IF v_current IS NULL THEN
162
+ v_current := 'none';
163
+ END IF;
164
+
165
+ IF v_current = p_new_status THEN
166
+ RETURN;
167
+ END IF;
168
+
169
+ UPDATE public.crm_contacts
170
+ SET status = p_new_status,
171
+ updated_at = now()
172
+ WHERE id = p_contact_id;
173
+
174
+ INSERT INTO public.crm_status_log (
175
+ contact_id,
176
+ previous_status,
177
+ new_status,
178
+ reason,
179
+ changed_by_user_id
180
+ ) VALUES (
181
+ p_contact_id,
182
+ v_current,
183
+ p_new_status,
184
+ p_reason,
185
+ auth.uid()
186
+ );
187
+ END;
188
+ $$;
189
+
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
+ ALTER TABLE public.crm_contacts ENABLE ROW LEVEL SECURITY;
282
+ 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
+
316
+ DROP POLICY IF EXISTS "Staff manage crm contacts" ON public.crm_contacts;
317
+ CREATE POLICY "Staff manage crm contacts"
318
+ ON public.crm_contacts
319
+ FOR ALL
320
+ TO authenticated
321
+ USING (public.is_staff())
322
+ WITH CHECK (public.is_staff());
323
+
324
+ DROP POLICY IF EXISTS "Staff view crm status log" ON public.crm_status_log;
325
+ CREATE POLICY "Staff view crm status log"
326
+ ON public.crm_status_log
327
+ FOR SELECT
328
+ TO authenticated
329
+ USING (public.is_staff());
330
+
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
+ UPDATE public.crm_contacts c
365
+ SET profile_id = p.id,
366
+ updated_at = now()
367
+ FROM public.profiles p
368
+ WHERE c.profile_id IS NULL
369
+ AND c.email IS NOT NULL
370
+ AND p.email IS NOT NULL
371
+ 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,22 @@
1
+ # Projects Migrations
2
+
3
+ `projects` owns work-management and collaboration tables.
4
+
5
+ ## Owns
6
+
7
+ - `projects`
8
+ - `project_members`
9
+ - `project_milestones`
10
+ - `project_tasks`
11
+ - `project_links`
12
+ - `project_status_log`
13
+ - project access helpers
14
+ - project activity triggers and policies
15
+
16
+ ## Dependency
17
+
18
+ Depends on:
19
+
20
+ - `core`
21
+ - `admin` for global role helpers
22
+ - `crm` when project access depends on organizations or organization membership