@brightweblabs/module-orgs 0.7.1 → 0.7.3

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.
@@ -43,7 +43,9 @@
43
43
  "touch_organization_invitations_updated_at",
44
44
  "trg_touch_organization_invitations_updated_at",
45
45
  "normalize_organization_invitation_email",
46
- "trg_normalize_organization_invitation_email"
46
+ "trg_normalize_organization_invitation_email",
47
+ "accept_organization_invitation",
48
+ "assign_organization_member_atomic"
47
49
  ]
48
50
  }
49
51
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@brightweblabs/module-orgs",
3
3
  "private": false,
4
- "version": "0.7.1",
4
+ "version": "0.7.3",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
7
7
  "files": [
@@ -25,10 +25,10 @@
25
25
  "@supabase/supabase-js": "^2.110.8",
26
26
  "lucide-react": "^1.26.0",
27
27
  "server-only": "^0.0.1",
28
- "@brightweblabs/app-shell": "0.16.1",
29
- "@brightweblabs/core-auth": "0.12.0",
30
- "@brightweblabs/infra": "0.7.0",
31
- "@brightweblabs/ui": "1.5.4"
28
+ "@brightweblabs/app-shell": "0.16.3",
29
+ "@brightweblabs/core-auth": "0.12.2",
30
+ "@brightweblabs/ui": "1.5.6",
31
+ "@brightweblabs/infra": "0.7.0"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "react": "^19.0.0",
package/src/handlers.ts CHANGED
@@ -25,10 +25,11 @@ import {
25
25
  createOrganizationsPostHandler,
26
26
  } from "./http";
27
27
 
28
- export function createOrganizationRequestHandlers(options?: { ensureCrmContactForProfile?: EnsureCrmContact }) {
28
+ export function createOrganizationRequestHandlers(options?: { ensureCrmContactForProfile?: EnsureCrmContact; contactIntegration?: "database" }) {
29
29
  const inviteMembers: typeof inviteOrganizationMembers = (supabase, organizationId, invites, actorProfileId) =>
30
30
  inviteOrganizationMembers(supabase, organizationId, invites, actorProfileId, {
31
31
  ensureCrmContactForProfile: options?.ensureCrmContactForProfile,
32
+ contactIntegration: options?.contactIntegration,
32
33
  });
33
34
  const writeDependencies = {
34
35
  getCreateAccess: requireOrganizationsStaffAccess,
package/src/index.ts CHANGED
@@ -33,6 +33,7 @@ export {
33
33
  } from "./handlers";
34
34
  export {
35
35
  ORGANIZATION_INVITE_EMAIL_DELIVERY_ERROR,
36
+ DATABASE_INVITATION_CONTACT_INTEGRATION,
36
37
  acceptOrganizationInvitation,
37
38
  getOrganizationInvitationDetails,
38
39
  inviteOrganizationMembers,
@@ -1,5 +1,5 @@
1
1
  import type { SupabaseClient } from "@supabase/supabase-js";
2
- import { syncOrganizationPrimaryContactFromAdmins, type OrganizationMemberRole } from "./data";
2
+ import type { OrganizationMemberRole } from "./data";
3
3
  import { sendOrganizationInviteEmail } from "./invite-email";
4
4
 
5
5
  const INVITE_EXPIRY_DAYS = 14;
@@ -34,6 +34,7 @@ export type OrganizationInvitationDetails = {
34
34
  role: OrganizationMemberRole;
35
35
  status: OrganizationInvitation["status"];
36
36
  expiresAt: string;
37
+ acceptedByProfileId?: string | null;
37
38
  };
38
39
 
39
40
  export type OrganizationInviteSummary = {
@@ -180,7 +181,7 @@ export async function getOrganizationInvitationDetails(
180
181
  ): Promise<OrganizationInvitationDetails | null> {
181
182
  const { data, error } = await supabase
182
183
  .from("organization_invitations")
183
- .select("id, organization_id, invited_email, role, status, expires_at, organizations(name)")
184
+ .select("id, organization_id, invited_email, role, status, expires_at, accepted_by_profile_id, organizations(name)")
184
185
  .eq("id", invitationId)
185
186
  .maybeSingle();
186
187
  if (error) throw new Error(error.message);
@@ -199,6 +200,7 @@ export async function getOrganizationInvitationDetails(
199
200
  role: raw.role === "admin" ? "admin" : "member",
200
201
  status: normalizeStatus(raw.status),
201
202
  expiresAt: String(raw.expires_at),
203
+ acceptedByProfileId: typeof raw.accepted_by_profile_id === "string" ? raw.accepted_by_profile_id : null,
202
204
  };
203
205
  }
204
206
 
@@ -264,8 +266,10 @@ export async function inviteOrganizationMembers(
264
266
  options?: {
265
267
  ensureCrmContactForProfile?: EnsureCrmContact;
266
268
  sendInviteEmail?: SendOrganizationInvite;
269
+ contactIntegration?: "database";
267
270
  },
268
271
  ): Promise<{ invitations: OrganizationInvitation[]; outcomes: OrganizationInviteOutcome[]; summary: OrganizationInviteSummary }> {
272
+ assertInvitationContactIntegration(options ?? {});
269
273
  const normalized = dedupeInviteDrafts(invites);
270
274
  const emptySummary = {
271
275
  pendingInvitations: 0,
@@ -280,12 +284,10 @@ export async function inviteOrganizationMembers(
280
284
  if (normalized.length === 0) return { invitations: [], outcomes: [], summary: emptySummary };
281
285
 
282
286
  const emails = normalized.map((invite) => invite.email);
283
- const [{ data: existingMembers, error: memberError }, { data: profiles, error: profileError }, { data: existingInvitations, error: invitationError }] = await Promise.all([
284
- supabase.from("organization_members").select("profile_id, role, profile:profiles!organization_members_profile_id_fkey(email)").eq("organization_id", organizationId),
287
+ const [{ data: profiles, error: profileError }, { data: existingInvitations, error: invitationError }] = await Promise.all([
285
288
  supabase.from("profiles").select("id, email").in("email", emails),
286
289
  supabase.from("organization_invitations").select("id, invited_email, role, status, expires_at").eq("organization_id", organizationId).in("invited_email", emails),
287
290
  ]);
288
- if (memberError) throw new Error(memberError.message);
289
291
  if (profileError) throw new Error(profileError.message);
290
292
  if (invitationError) throw new Error(invitationError.message);
291
293
  const { data: organization, error: organizationError } = await supabase
@@ -296,14 +298,6 @@ export async function inviteOrganizationMembers(
296
298
  if (organizationError) throw new Error(organizationError.message);
297
299
  const organizationName = organization?.name?.trim() || "Organização";
298
300
 
299
- const memberByEmail = new Map<string, { profileId: string; role: OrganizationMemberRole }>();
300
- for (const row of existingMembers ?? []) {
301
- const rawProfile = Array.isArray(row.profile) ? row.profile[0] ?? null : row.profile;
302
- const email = rawProfile && typeof rawProfile.email === "string" ? normalizeEmail(rawProfile.email) : "";
303
- if (email && typeof row.profile_id === "string") {
304
- memberByEmail.set(email, { profileId: row.profile_id, role: row.role === "admin" ? "admin" : "member" });
305
- }
306
- }
307
301
  const profileByEmail = new Map<string, string>();
308
302
  for (const profile of profiles ?? []) {
309
303
  if (typeof profile.id === "string" && typeof profile.email === "string") {
@@ -325,76 +319,38 @@ export async function inviteOrganizationMembers(
325
319
  }
326
320
 
327
321
  const pendingRows: Array<Record<string, unknown>> = [];
328
- const resolvedProfilesByEmail = new Map<string, string>();
329
322
  const outcomes: OrganizationInviteOutcome[] = [];
330
323
  let directAssignments = 0;
331
324
  let updatedExistingMembers = 0;
332
325
  let unchangedExistingMembers = 0;
333
326
  for (const invite of normalized) {
334
- const member = memberByEmail.get(invite.email);
335
- if (member) {
336
- if (member.role === invite.role) {
337
- if (options?.ensureCrmContactForProfile) {
338
- const linked = await options.ensureCrmContactForProfile(member.profileId, {
339
- source: "organization_member_direct_access", organizationId, serviceClient: supabase,
340
- });
341
- if (!linked.success) {
342
- outcomes.push({ email: invite.email, role: invite.role, status: "api_failed", profileId: member.profileId, message: "Não foi possível ligar o contacto CRM.", failureKind: "crm_link" });
343
- continue;
344
- }
345
- }
346
- unchangedExistingMembers += 1;
347
- outcomes.push({ email: invite.email, role: invite.role, status: "already_member", profileId: member.profileId });
348
- }
349
- else {
350
- const { error } = await supabase.from("organization_members").update({ role: invite.role })
351
- .eq("organization_id", organizationId).eq("profile_id", member.profileId);
352
- if (error) {
353
- outcomes.push({ email: invite.email, role: invite.role, status: "api_failed", profileId: member.profileId, message: "Não foi possível atualizar a função do membro.", failureKind: "membership" });
354
- continue;
355
- }
356
- if (options?.ensureCrmContactForProfile) {
357
- const linked = await options.ensureCrmContactForProfile(member.profileId, {
358
- source: "organization_member_direct_access", organizationId, serviceClient: supabase,
359
- });
360
- if (!linked.success) {
361
- const { error: rollbackError } = await supabase.from("organization_members").update({ role: member.role })
362
- .eq("organization_id", organizationId).eq("profile_id", member.profileId);
363
- outcomes.push({ email: invite.email, role: invite.role, status: "api_failed", profileId: member.profileId, message: rollbackError ? "Não foi possível ligar o contacto CRM nem reverter a alteração de função; é necessária reconciliação." : "Não foi possível ligar o contacto CRM; a alteração de função foi revertida.", failureKind: "crm_link" });
364
- continue;
365
- }
366
- }
367
- updatedExistingMembers += 1;
368
- outcomes.push({ email: invite.email, role: invite.role, status: "membership_updated", profileId: member.profileId });
369
- }
370
- resolvedProfilesByEmail.set(invite.email, member.profileId);
371
- continue;
372
- }
373
327
  const profileId = profileByEmail.get(invite.email);
374
328
  if (profileId) {
375
- const { error } = await supabase.from("organization_members").upsert({
376
- organization_id: organizationId,
377
- profile_id: profileId,
378
- role: invite.role,
379
- }, { onConflict: "organization_id,profile_id" });
380
- if (error) {
381
- outcomes.push({ email: invite.email, role: invite.role, status: "api_failed", profileId, message: "Não foi possível conceder o acesso à organização.", failureKind: "membership" });
382
- continue;
383
- }
384
- if (options?.ensureCrmContactForProfile) {
385
- const linked = await options.ensureCrmContactForProfile(profileId, {
386
- source: "organization_member_direct_access", organizationId, serviceClient: supabase,
329
+ // The database resolves current membership under lock and rolls back only
330
+ // this transaction's writes. Never compensate from an earlier snapshot.
331
+ const { data, error } = await supabase.rpc("assign_organization_member_atomic", {
332
+ p_organization_id: organizationId,
333
+ p_profile_id: profileId,
334
+ p_email: invite.email,
335
+ p_role: invite.role,
336
+ p_actor_profile_id: actorProfileId,
337
+ });
338
+ const status = data?.status;
339
+ if (error || !["immediate_access", "membership_updated", "already_member"].includes(status)) {
340
+ const contactFailure = error?.code === "BW001";
341
+ outcomes.push({
342
+ email: invite.email, role: invite.role, status: "api_failed", profileId,
343
+ message: contactFailure
344
+ ? "Não foi possível ligar o contacto CRM. Nenhuma alteração de acesso foi guardada."
345
+ : "Não foi possível confirmar a alteração de acesso à organização. Tente novamente.",
346
+ failureKind: contactFailure ? "crm_link" : "membership",
387
347
  });
388
- if (!linked.success) {
389
- const { error: rollbackError } = await supabase.from("organization_members").delete()
390
- .eq("organization_id", organizationId).eq("profile_id", profileId);
391
- outcomes.push({ email: invite.email, role: invite.role, status: "api_failed", profileId, message: rollbackError ? "Não foi possível ligar o contacto CRM nem reverter o acesso; é necessária reconciliação." : "Não foi possível ligar o contacto CRM; o acesso não foi mantido.", failureKind: "crm_link" });
392
- continue;
393
- }
348
+ continue;
394
349
  }
395
- resolvedProfilesByEmail.set(invite.email, profileId);
396
- directAssignments += 1;
397
- outcomes.push({ email: invite.email, role: invite.role, status: "immediate_access", profileId });
350
+ if (status === "immediate_access") directAssignments += 1;
351
+ else if (status === "membership_updated") updatedExistingMembers += 1;
352
+ else unchangedExistingMembers += 1;
353
+ outcomes.push({ email: invite.email, role: invite.role, status, profileId });
398
354
  continue;
399
355
  }
400
356
  const pendingInvitation = pendingInvitationByEmail.get(invite.email);
@@ -416,21 +372,6 @@ export async function inviteOrganizationMembers(
416
372
  });
417
373
  }
418
374
 
419
- if (directAssignments > 0 || updatedExistingMembers > 0) {
420
- try { await syncOrganizationPrimaryContactFromAdmins(supabase, organizationId); } catch { /* Retry on the next idempotent member operation. */ }
421
- }
422
- if (resolvedProfilesByEmail.size > 0) {
423
- const acceptedAt = new Date().toISOString();
424
- await Promise.all(Array.from(resolvedProfilesByEmail, async ([email, profileId]) => {
425
- const { error } = await supabase.from("organization_invitations").update({
426
- status: "accepted",
427
- accepted_at: acceptedAt,
428
- accepted_by_profile_id: profileId,
429
- }).eq("organization_id", organizationId).eq("invited_email", email).eq("status", "pending");
430
- // Access is already effective. Leave a pending row for the next idempotent call to reconcile.
431
- if (error) return;
432
- }));
433
- }
434
375
  if (pendingRows.length > 0) {
435
376
  const { error } = await supabase.from("organization_invitations").upsert(pendingRows, {
436
377
  onConflict: "organization_id,invited_email",
@@ -467,22 +408,24 @@ export async function inviteOrganizationMembers(
467
408
  }),
468
409
  })));
469
410
  const failedIds = deliveries.filter((result) => !result.delivered).map((result) => result.id);
411
+ let cleanupFailed = false;
470
412
  if (failedIds.length > 0) {
471
413
  const { error: deleteError } = await supabase
472
414
  .from("organization_invitations")
473
415
  .delete()
474
416
  .in("id", failedIds)
475
417
  .eq("status", "pending");
476
- // Preserve the email failure outcome. A remaining row is safe to revoke/retry and stays visible as pending.
477
- void deleteError;
418
+ cleanupFailed = Boolean(deleteError);
478
419
  }
479
420
  for (const delivery of deliveries) {
480
421
  outcomes.push({
481
422
  email: delivery.email,
482
423
  role: delivery.role,
483
424
  status: delivery.delivered ? "pending_invitation" : "email_failed",
484
- invitationId: delivery.delivered ? delivery.id : undefined,
485
- message: delivery.delivered ? undefined : ORGANIZATION_INVITE_EMAIL_DELIVERY_ERROR,
425
+ invitationId: delivery.delivered || cleanupFailed ? delivery.id : undefined,
426
+ message: delivery.delivered ? undefined : cleanupFailed
427
+ ? "Não foi possível enviar o email. O convite pendente foi mantido; reenvie-o ou revogue-o."
428
+ : ORGANIZATION_INVITE_EMAIL_DELIVERY_ERROR,
486
429
  });
487
430
  }
488
431
  }
@@ -572,61 +515,22 @@ export async function acceptOrganizationInvitation(
572
515
  invitationId: string;
573
516
  profileId: string;
574
517
  userEmail: string;
575
- ensureCrmContactForProfile: EnsureCrmContact;
518
+ ensureCrmContactForProfile?: EnsureCrmContact;
519
+ contactIntegration?: "database";
576
520
  },
577
521
  ): Promise<{ organizationId: string }> {
578
- const email = normalizeEmail(params.userEmail);
579
- const { data: invitation, error } = await supabase
580
- .from("organization_invitations")
581
- .select("id, organization_id, invited_email, role, status, expires_at")
582
- .eq("id", params.invitationId)
583
- .maybeSingle<{ id: string; organization_id: string; invited_email: string; role: string; status: string; expires_at: string }>();
584
- if (error) throw new Error(error.message);
585
- if (!invitation?.id) throw new Error("Convite não encontrado.");
586
- if (normalizeEmail(invitation.invited_email) !== email) throw new Error("Este convite pertence a outro email.");
587
- if (invitation.status !== "pending") throw new Error("Este convite já não está disponível.");
588
- if (isInvitationExpired(invitation.expires_at)) {
589
- const { error: expireError } = await supabase.from("organization_invitations")
590
- .update({ status: "expired" }).eq("id", invitation.id);
591
- if (expireError) throw new Error(expireError.message);
592
- throw new Error("Este convite expirou.");
593
- }
594
-
595
- const acceptedContact = await params.ensureCrmContactForProfile(params.profileId, {
596
- source: "organization_invitation_accept",
597
- organizationId: invitation.organization_id,
598
- serviceClient: supabase,
522
+ assertInvitationContactIntegration(params);
523
+ const { data, error } = await supabase.rpc("accept_organization_invitation", {
524
+ p_invitation_id: params.invitationId,
525
+ p_profile_id: params.profileId,
526
+ p_user_email: normalizeEmail(params.userEmail),
599
527
  });
600
- if (!acceptedContact.success) {
601
- throw new Error(acceptedContact.error ?? "Não foi possível associar o contacto CRM ao convite.");
528
+ if (error) throw new Error(error.message);
529
+ if (data?.status === "expired") throw new Error("Este convite expirou.");
530
+ if (data?.status !== "accepted" || typeof data.organizationId !== "string") {
531
+ throw new Error("Não foi possível confirmar a aceitação do convite.");
602
532
  }
603
- const role: OrganizationMemberRole = invitation.role === "admin" ? "admin" : "member";
604
- const { error: memberError } = await supabase.from("organization_members").upsert({
605
- organization_id: invitation.organization_id,
606
- profile_id: params.profileId,
607
- role,
608
- }, { onConflict: "organization_id,profile_id" });
609
- if (memberError) throw new Error(memberError.message);
610
- if (role === "admin") await syncOrganizationPrimaryContactFromAdmins(supabase, invitation.organization_id);
611
- const { error: updateError } = await supabase.from("organization_invitations").update({
612
- status: "accepted",
613
- accepted_at: new Date().toISOString(),
614
- accepted_by_profile_id: params.profileId,
615
- accepted_contact_id: acceptedContact.contactId ?? null,
616
- }).eq("id", invitation.id);
617
- if (updateError) throw new Error(updateError.message);
618
- await logOrganizationActivity(supabase, {
619
- actorProfileId: params.profileId,
620
- organizationId: invitation.organization_id,
621
- eventType: "crm_organization_invitation_accepted",
622
- summary: "Convite de organização aceite.",
623
- payload: {
624
- invitation_id: invitation.id,
625
- organization_id: invitation.organization_id,
626
- email,
627
- },
628
- });
629
- return { organizationId: invitation.organization_id };
533
+ return { organizationId: data.organizationId };
630
534
  }
631
535
 
632
536
  export async function registerUserFromOrganizationInvitation(
@@ -636,13 +540,26 @@ export async function registerUserFromOrganizationInvitation(
636
540
  firstName: string;
637
541
  lastName: string;
638
542
  password: string;
639
- ensureCrmContactForProfile: EnsureCrmContact;
543
+ ensureCrmContactForProfile?: EnsureCrmContact;
544
+ contactIntegration?: "database";
640
545
  },
641
546
  ): Promise<{ email: string; organizationId: string }> {
547
+ assertInvitationContactIntegration(params);
642
548
  const invitation = await getOrganizationInvitationDetails(supabase, params.invitationId);
643
549
  if (!invitation) throw new Error("INVITATION_NOT_FOUND");
644
- if (invitation.status !== "pending") throw new Error("INVITATION_NOT_AVAILABLE");
645
- if (isInvitationExpired(invitation.expiresAt)) {
550
+ if (invitation.status !== "pending" && invitation.status !== "accepted") throw new Error("INVITATION_NOT_AVAILABLE");
551
+ if (invitation.status === "accepted") {
552
+ if (!invitation.acceptedByProfileId) throw new Error("INVITATION_NOT_AVAILABLE");
553
+ const accepted = await acceptOrganizationInvitation(supabase, {
554
+ invitationId: invitation.id,
555
+ profileId: invitation.acceptedByProfileId,
556
+ userEmail: invitation.invitedEmail,
557
+ ensureCrmContactForProfile: params.ensureCrmContactForProfile,
558
+ contactIntegration: params.contactIntegration,
559
+ });
560
+ return { email: invitation.invitedEmail, ...accepted };
561
+ }
562
+ if (invitation.status === "pending" && isInvitationExpired(invitation.expiresAt)) {
646
563
  const { error } = await supabase.from("organization_invitations")
647
564
  .update({ status: "expired" }).eq("id", invitation.id).eq("status", "pending");
648
565
  if (error) throw new Error(error.message);
@@ -658,31 +575,40 @@ export async function registerUserFromOrganizationInvitation(
658
575
  user_metadata: { first_name: firstName || null, last_name: lastName || null },
659
576
  });
660
577
  let userId = data.user?.id ?? null;
661
- let createdUserId = userId;
662
578
  if (error) {
663
579
  if (!isExistingAccountError(error)) throw new Error(error.message);
664
580
  userId = await findAuthUserIdByEmail(supabase, invitation.invitedEmail);
665
581
  if (!userId) throw new Error("ACCOUNT_ALREADY_EXISTS");
666
- createdUserId = null;
667
582
  }
668
583
  if (!userId) throw new Error("Não foi possível criar utilizador.");
669
584
 
670
- try {
671
- const profile = await ensureInvitationProfile(supabase, {
672
- userId,
673
- email: invitation.invitedEmail,
674
- firstName,
675
- lastName,
676
- });
677
- await acceptOrganizationInvitation(supabase, {
678
- invitationId: invitation.id,
679
- profileId: profile.id,
680
- userEmail: invitation.invitedEmail,
681
- ensureCrmContactForProfile: params.ensureCrmContactForProfile,
682
- });
683
- } catch (caught) {
684
- if (createdUserId) await supabase.auth.admin.deleteUser(createdUserId);
685
- throw caught;
585
+ // A failed/unknown RPC response must not delete an identity whose acceptance
586
+ // may have committed. The next registration attempt resolves the same account.
587
+ const profile = await ensureInvitationProfile(supabase, {
588
+ userId,
589
+ email: invitation.invitedEmail,
590
+ firstName,
591
+ lastName,
592
+ });
593
+ const accepted = await acceptOrganizationInvitation(supabase, {
594
+ invitationId: invitation.id,
595
+ profileId: profile.id,
596
+ userEmail: invitation.invitedEmail,
597
+ ensureCrmContactForProfile: params.ensureCrmContactForProfile,
598
+ contactIntegration: params.contactIntegration,
599
+ });
600
+ return { email: invitation.invitedEmail, ...accepted };
601
+ }
602
+
603
+ /** Marks a stock callback whose invitation behavior is implemented by the shipped SQL hook. */
604
+ export const DATABASE_INVITATION_CONTACT_INTEGRATION = Symbol.for("brightweb.orgs.database-invitation-contact-integration.v1");
605
+
606
+ function assertInvitationContactIntegration(params: {
607
+ ensureCrmContactForProfile?: EnsureCrmContact;
608
+ contactIntegration?: "database";
609
+ }): void {
610
+ if (params.ensureCrmContactForProfile && params.contactIntegration !== "database" &&
611
+ Reflect.get(params.ensureCrmContactForProfile, DATABASE_INVITATION_CONTACT_INTEGRATION) !== true) {
612
+ throw new Error("INVITATION_CONTACT_INTEGRATION_MIGRATION_REQUIRED");
686
613
  }
687
- return { email: invitation.invitedEmail, organizationId: invitation.organizationId };
688
614
  }