@brightweblabs/module-orgs 0.6.3 → 0.7.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@brightweblabs/module-orgs",
3
3
  "private": false,
4
- "version": "0.6.3",
4
+ "version": "0.7.0",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
7
7
  "files": [
@@ -26,8 +26,8 @@
26
26
  "lucide-react": "^1.26.0",
27
27
  "server-only": "^0.0.1",
28
28
  "@brightweblabs/app-shell": "0.16.0",
29
- "@brightweblabs/infra": "0.7.0",
30
29
  "@brightweblabs/core-auth": "0.12.0",
30
+ "@brightweblabs/infra": "0.7.0",
31
31
  "@brightweblabs/ui": "1.5.4"
32
32
  },
33
33
  "peerDependencies": {
package/src/data.ts CHANGED
@@ -289,6 +289,25 @@ export async function setOrganizationMemberRole(
289
289
  return data as OrganizationMember;
290
290
  }
291
291
 
292
+ export async function syncOrganizationPrimaryContactFromAdmins(
293
+ supabase: SupabaseClient,
294
+ organizationId: string,
295
+ ): Promise<void> {
296
+ const { data, error } = await supabase
297
+ .from("organization_members")
298
+ .select("profile_id")
299
+ .eq("organization_id", organizationId)
300
+ .eq("role", "admin")
301
+ .order("joined_at", { ascending: true })
302
+ .limit(1);
303
+ if (error) throw new Error(error.message);
304
+ const { error: updateError } = await supabase
305
+ .from("organizations")
306
+ .update({ primary_contact_id: typeof data?.[0]?.profile_id === "string" ? data[0].profile_id : null })
307
+ .eq("id", organizationId);
308
+ if (updateError) throw new Error(updateError.message);
309
+ }
310
+
292
311
  async function requireSafeOrganizationMemberMutation(
293
312
  supabase: SupabaseClient,
294
313
  organizationId: string,
@@ -305,21 +324,6 @@ async function requireSafeOrganizationMemberMutation(
305
324
  return member;
306
325
  }
307
326
 
308
- async function assertOrganizationKeepsAdmin(
309
- supabase: SupabaseClient,
310
- organizationId: string,
311
- member: OrganizationMember,
312
- ): Promise<void> {
313
- if (member.role !== "admin") return;
314
- const { count, error } = await supabase
315
- .from("organization_members")
316
- .select("id", { count: "exact", head: true })
317
- .eq("organization_id", organizationId)
318
- .eq("role", "admin");
319
- if (error) throw new Error(error.message);
320
- if ((count ?? 0) <= 1) throw new Error("A organização deve manter pelo menos um Administrador.");
321
- }
322
-
323
327
  export async function updateOrganizationMemberRole(
324
328
  supabase: SupabaseClient,
325
329
  organizationId: string,
@@ -328,8 +332,11 @@ export async function updateOrganizationMemberRole(
328
332
  ): Promise<OrganizationMember> {
329
333
  const member = await requireSafeOrganizationMemberMutation(supabase, organizationId, profileId);
330
334
  if (member.role === role) return member;
331
- if (role === "member") await assertOrganizationKeepsAdmin(supabase, organizationId, member);
332
- return setOrganizationMemberRole(supabase, organizationId, profileId, role);
335
+ const updated = await setOrganizationMemberRole(supabase, organizationId, profileId, role);
336
+ if (member.role === "admin" || role === "admin") {
337
+ await syncOrganizationPrimaryContactFromAdmins(supabase, organizationId);
338
+ }
339
+ return updated;
333
340
  }
334
341
 
335
342
  export async function removeOrganizationMember(
@@ -338,11 +345,11 @@ export async function removeOrganizationMember(
338
345
  profileId: string,
339
346
  ): Promise<void> {
340
347
  const member = await requireSafeOrganizationMemberMutation(supabase, organizationId, profileId);
341
- await assertOrganizationKeepsAdmin(supabase, organizationId, member);
342
348
  const { error } = await supabase
343
349
  .from("organization_members")
344
350
  .delete()
345
351
  .eq("organization_id", organizationId)
346
352
  .eq("profile_id", profileId);
347
353
  if (error) throw new Error(error.message);
354
+ if (member.role === "admin") await syncOrganizationPrimaryContactFromAdmins(supabase, organizationId);
348
355
  }
package/src/handlers.ts CHANGED
@@ -11,10 +11,13 @@ import {
11
11
  logOrganizationActivity,
12
12
  listOrganizationInvitations,
13
13
  listOrganizationMemberViews,
14
+ resendOrganizationInvitation,
14
15
  revokeOrganizationInvitation,
16
+ type EnsureCrmContact,
15
17
  } from "./invitations";
16
18
  import {
17
19
  createOrganizationInvitationDeleteHandler,
20
+ createOrganizationInvitationResendHandler,
18
21
  createOrganizationDeleteHandler,
19
22
  createOrganizationInvitationsHandler,
20
23
  createOrganizationMemberMutationHandlers,
@@ -22,40 +25,56 @@ import {
22
25
  createOrganizationsPostHandler,
23
26
  } from "./http";
24
27
 
25
- const writeDependencies = {
26
- getCreateAccess: requireOrganizationsStaffAccess,
27
- getManageAccess: requireOrganizationManageAccess,
28
- createOrganization,
29
- updateOrganization,
30
- deleteOrganization,
31
- inviteMembers: inviteOrganizationMembers,
32
- logActivity: logOrganizationActivity,
33
- };
34
-
35
- const invitationDependencies = {
36
- getManageAccess: requireOrganizationManageAccess,
37
- inviteMembers: inviteOrganizationMembers,
38
- listInvitations: listOrganizationInvitations,
39
- listMembers: listOrganizationMemberViews,
40
- revokeInvitation: revokeOrganizationInvitation,
41
- logActivity: logOrganizationActivity,
42
- };
43
-
44
- export const handleOrganizationsPostRequest = createOrganizationsPostHandler(writeDependencies);
45
- export const handleOrganizationPatchRequest = createOrganizationPatchHandler(writeDependencies);
46
- export const handleOrganizationDeleteRequest = createOrganizationDeleteHandler(writeDependencies);
47
-
48
- const invitationHandlers = createOrganizationInvitationsHandler(invitationDependencies);
49
- export const handleOrganizationInvitationsGetRequest = invitationHandlers.GET;
50
- export const handleOrganizationInvitationsPostRequest = invitationHandlers.POST;
51
- export const handleOrganizationInvitationDeleteRequest =
52
- createOrganizationInvitationDeleteHandler(invitationDependencies);
28
+ export function createOrganizationRequestHandlers(options?: { ensureCrmContactForProfile?: EnsureCrmContact }) {
29
+ const inviteMembers: typeof inviteOrganizationMembers = (supabase, organizationId, invites, actorProfileId) =>
30
+ inviteOrganizationMembers(supabase, organizationId, invites, actorProfileId, {
31
+ ensureCrmContactForProfile: options?.ensureCrmContactForProfile,
32
+ });
33
+ const writeDependencies = {
34
+ getCreateAccess: requireOrganizationsStaffAccess,
35
+ getManageAccess: requireOrganizationManageAccess,
36
+ createOrganization,
37
+ updateOrganization,
38
+ deleteOrganization,
39
+ inviteMembers,
40
+ logActivity: logOrganizationActivity,
41
+ };
42
+ const invitationDependencies = {
43
+ getManageAccess: requireOrganizationManageAccess,
44
+ inviteMembers,
45
+ listInvitations: listOrganizationInvitations,
46
+ listMembers: listOrganizationMemberViews,
47
+ revokeInvitation: revokeOrganizationInvitation,
48
+ resendInvitation: resendOrganizationInvitation,
49
+ logActivity: logOrganizationActivity,
50
+ };
51
+ const invitationHandlers = createOrganizationInvitationsHandler(invitationDependencies);
52
+ const memberMutationHandlers = createOrganizationMemberMutationHandlers({
53
+ getManageAccess: requireOrganizationManageAccess,
54
+ updateMemberRole: updateOrganizationMemberRole,
55
+ removeMember: removeOrganizationMember,
56
+ logActivity: logOrganizationActivity,
57
+ });
58
+ return {
59
+ organizationsPost: createOrganizationsPostHandler(writeDependencies),
60
+ organizationPatch: createOrganizationPatchHandler(writeDependencies),
61
+ organizationDelete: createOrganizationDeleteHandler(writeDependencies),
62
+ invitationsGet: invitationHandlers.GET,
63
+ invitationsPost: invitationHandlers.POST,
64
+ invitationDelete: createOrganizationInvitationDeleteHandler(invitationDependencies),
65
+ invitationResend: createOrganizationInvitationResendHandler(invitationDependencies),
66
+ memberPatch: memberMutationHandlers.PATCH,
67
+ memberDelete: memberMutationHandlers.DELETE,
68
+ };
69
+ }
53
70
 
54
- const memberMutationHandlers = createOrganizationMemberMutationHandlers({
55
- getManageAccess: requireOrganizationManageAccess,
56
- updateMemberRole: updateOrganizationMemberRole,
57
- removeMember: removeOrganizationMember,
58
- logActivity: logOrganizationActivity,
59
- });
60
- export const handleOrganizationMemberPatchRequest = memberMutationHandlers.PATCH;
61
- export const handleOrganizationMemberDeleteRequest = memberMutationHandlers.DELETE;
71
+ const handlers = createOrganizationRequestHandlers();
72
+ export const handleOrganizationsPostRequest = handlers.organizationsPost;
73
+ export const handleOrganizationPatchRequest = handlers.organizationPatch;
74
+ export const handleOrganizationDeleteRequest = handlers.organizationDelete;
75
+ export const handleOrganizationInvitationsGetRequest = handlers.invitationsGet;
76
+ export const handleOrganizationInvitationsPostRequest = handlers.invitationsPost;
77
+ export const handleOrganizationInvitationDeleteRequest = handlers.invitationDelete;
78
+ export const handleOrganizationInvitationResendRequest = handlers.invitationResend;
79
+ export const handleOrganizationMemberPatchRequest = handlers.memberPatch;
80
+ export const handleOrganizationMemberDeleteRequest = handlers.memberDelete;
package/src/http.ts CHANGED
@@ -6,6 +6,7 @@ import type {
6
6
  logOrganizationActivity,
7
7
  listOrganizationInvitations,
8
8
  listOrganizationMemberViews,
9
+ resendOrganizationInvitation,
9
10
  revokeOrganizationInvitation,
10
11
  } from "./invitations";
11
12
 
@@ -36,6 +37,7 @@ type OrganizationInvitationDependencies = {
36
37
  listInvitations: typeof listOrganizationInvitations;
37
38
  listMembers: typeof listOrganizationMemberViews;
38
39
  revokeInvitation: typeof revokeOrganizationInvitation;
40
+ resendInvitation: typeof resendOrganizationInvitation;
39
41
  logActivity: typeof logOrganizationActivity;
40
42
  };
41
43
 
@@ -90,9 +92,10 @@ function organizationError(error: unknown): Response {
90
92
  if (message === "Convite pendente não encontrado.") {
91
93
  return json({ error: message }, { status: 404 });
92
94
  }
93
- if (message.startsWith("Não foi possível enviar o email de convite.")) {
95
+ if (message.startsWith("Não foi possível enviar o email de convite.") || message.startsWith("Não foi possível reenviar o email de convite.")) {
94
96
  return json({ error: message }, { status: 502 });
95
97
  }
98
+ if (message === "Este convite expirou.") return json({ error: message }, { status: 410 });
96
99
  return json({ error: message || "Erro interno do servidor." }, { status: 500 });
97
100
  }
98
101
 
@@ -120,10 +123,13 @@ export function createOrganizationsPostHandler(dependencies: OrganizationWriteDe
120
123
  payload: {
121
124
  organization_id: organization.id,
122
125
  pending_invitations: result.summary.pendingInvitations,
126
+ duplicate_pending_invitations: result.summary.duplicatePendingInvitations,
123
127
  direct_assignments: result.summary.directAssignments,
128
+ failed_email_deliveries: result.summary.failedEmailDeliveries,
129
+ failed_api_operations: result.summary.failedApiOperations,
124
130
  },
125
131
  });
126
- return json({ data: { organization, invitations: result.invitations, inviteSummary: result.summary } }, { status: 201 });
132
+ return json({ data: { organization, invitations: result.invitations, outcomes: result.outcomes, inviteSummary: result.summary } }, { status: 201 });
127
133
  } catch (error) {
128
134
  return organizationError(error);
129
135
  }
@@ -161,9 +167,12 @@ export function createOrganizationPatchHandler(dependencies: OrganizationWriteDe
161
167
  pending_invitations: result.summary.pendingInvitations,
162
168
  direct_assignments: result.summary.directAssignments,
163
169
  updated_existing_members: result.summary.updatedExistingMembers,
170
+ duplicate_pending_invitations: result.summary.duplicatePendingInvitations,
171
+ failed_email_deliveries: result.summary.failedEmailDeliveries,
172
+ failed_api_operations: result.summary.failedApiOperations,
164
173
  },
165
174
  });
166
- return json({ data: { organization, invitations: result.invitations, inviteSummary: result.summary } });
175
+ return json({ data: { organization, invitations: result.invitations, outcomes: result.outcomes, inviteSummary: result.summary } });
167
176
  } catch (error) {
168
177
  return organizationError(error);
169
178
  }
@@ -245,9 +254,12 @@ export function createOrganizationInvitationsHandler(dependencies: OrganizationI
245
254
  pending_invitations: result.summary.pendingInvitations,
246
255
  direct_assignments: result.summary.directAssignments,
247
256
  updated_existing_members: result.summary.updatedExistingMembers,
257
+ duplicate_pending_invitations: result.summary.duplicatePendingInvitations,
258
+ failed_email_deliveries: result.summary.failedEmailDeliveries,
259
+ failed_api_operations: result.summary.failedApiOperations,
248
260
  },
249
261
  });
250
- return json({ data: { invitations: result.invitations, inviteSummary: result.summary } }, { status: 201 });
262
+ return json({ data: { invitations: result.invitations, outcomes: result.outcomes, inviteSummary: result.summary } }, { status: 201 });
251
263
  } catch (error) {
252
264
  return organizationError(error);
253
265
  }
@@ -337,3 +349,28 @@ export function createOrganizationInvitationDeleteHandler(dependencies: Organiza
337
349
  }
338
350
  };
339
351
  }
352
+
353
+ export function createOrganizationInvitationResendHandler(dependencies: OrganizationInvitationDependencies) {
354
+ return async function handleOrganizationInvitationResendRequest(
355
+ _request: Request,
356
+ context: { params: Promise<{ id: string; invitationId: string }> },
357
+ ): Promise<Response> {
358
+ const { id, invitationId } = await context.params;
359
+ if (!id || !invitationId) return json({ error: "id e invitationId são obrigatórios." }, { status: 400 });
360
+ const access = await dependencies.getManageAccess(id);
361
+ if (!access.ok) return json({ error: access.error }, { status: access.status });
362
+ try {
363
+ const invitation = await dependencies.resendInvitation(access.serviceSupabase, id, invitationId);
364
+ await dependencies.logActivity(access.serviceSupabase, {
365
+ actorProfileId: access.profileId,
366
+ organizationId: id,
367
+ eventType: "crm_organization_invitation_resent",
368
+ summary: "Convite da organização CRM reenviado.",
369
+ payload: { organization_id: id, invitation_id: invitationId },
370
+ });
371
+ return json({ data: { invitation } });
372
+ } catch (error) {
373
+ return organizationError(error);
374
+ }
375
+ };
376
+ }
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ export {
7
7
  listOrganizations,
8
8
  removeOrganizationMember,
9
9
  setOrganizationMemberRole,
10
+ syncOrganizationPrimaryContactFromAdmins,
10
11
  updateOrganizationMemberRole,
11
12
  updateOrganization,
12
13
  type CreateOrganizationInput,
@@ -19,7 +20,9 @@ export {
19
20
  type UpdateOrganizationInput,
20
21
  } from "./data";
21
22
  export {
23
+ createOrganizationRequestHandlers,
22
24
  handleOrganizationInvitationDeleteRequest,
25
+ handleOrganizationInvitationResendRequest,
23
26
  handleOrganizationDeleteRequest,
24
27
  handleOrganizationInvitationsGetRequest,
25
28
  handleOrganizationInvitationsPostRequest,
@@ -37,11 +40,15 @@ export {
37
40
  listOrganizationMemberViews,
38
41
  logOrganizationActivity,
39
42
  registerUserFromOrganizationInvitation,
43
+ resendOrganizationInvitation,
40
44
  revokeOrganizationInvitation,
41
45
  type EnsureCrmContact,
46
+ type SendOrganizationInvite,
42
47
  type OrganizationInvitation,
43
48
  type OrganizationInvitationDetails,
44
49
  type OrganizationInviteDraft,
50
+ type OrganizationInviteOutcome,
51
+ type OrganizationInviteOutcomeStatus,
45
52
  type OrganizationInviteSummary,
46
53
  type OrganizationMemberView,
47
54
  } from "./invitations";
@@ -1,5 +1,5 @@
1
1
  import type { SupabaseClient } from "@supabase/supabase-js";
2
- import type { OrganizationMemberRole } from "./data";
2
+ import { syncOrganizationPrimaryContactFromAdmins, type OrganizationMemberRole } from "./data";
3
3
  import { sendOrganizationInviteEmail } from "./invite-email";
4
4
 
5
5
  const INVITE_EXPIRY_DAYS = 14;
@@ -38,10 +38,32 @@ export type OrganizationInvitationDetails = {
38
38
 
39
39
  export type OrganizationInviteSummary = {
40
40
  pendingInvitations: number;
41
+ duplicatePendingInvitations: number;
41
42
  directAssignments: number;
42
43
  updatedExistingMembers: number;
43
44
  unchangedExistingMembers: number;
44
45
  failedEmailDeliveries: number;
46
+ failedContactLinks: number;
47
+ failedApiOperations: number;
48
+ };
49
+
50
+ export type OrganizationInviteOutcomeStatus =
51
+ | "immediate_access"
52
+ | "membership_updated"
53
+ | "already_member"
54
+ | "pending_invitation"
55
+ | "duplicate_pending"
56
+ | "email_failed"
57
+ | "api_failed";
58
+
59
+ export type OrganizationInviteOutcome = {
60
+ email: string;
61
+ role: OrganizationMemberRole;
62
+ status: OrganizationInviteOutcomeStatus;
63
+ profileId?: string;
64
+ invitationId?: string;
65
+ message?: string;
66
+ failureKind?: "crm_link" | "membership" | "invitation";
45
67
  };
46
68
 
47
69
  export type OrganizationMemberView = {
@@ -58,6 +80,8 @@ export type EnsureCrmContact = (
58
80
  options: { source: string; organizationId?: string | null; serviceClient: SupabaseClient },
59
81
  ) => Promise<{ success: boolean; contactId?: string; error?: string }>;
60
82
 
83
+ export type SendOrganizationInvite = typeof sendOrganizationInviteEmail;
84
+
61
85
  export async function logOrganizationActivity(
62
86
  supabase: SupabaseClient,
63
87
  input: {
@@ -89,14 +113,16 @@ function normalizeStatus(value: unknown): OrganizationInvitation["status"] {
89
113
  }
90
114
 
91
115
  function normalizeInvitation(raw: Record<string, unknown>): OrganizationInvitation {
116
+ const expiresAt = String(raw.expires_at);
117
+ const storedStatus = normalizeStatus(raw.status);
92
118
  return {
93
119
  id: String(raw.id),
94
120
  organizationId: String(raw.organization_id),
95
121
  email: typeof raw.invited_email === "string" ? normalizeEmail(raw.invited_email) : "",
96
122
  role: raw.role === "admin" ? "admin" : "member",
97
- status: normalizeStatus(raw.status),
123
+ status: storedStatus === "pending" && isInvitationExpired(expiresAt) ? "expired" : storedStatus,
98
124
  createdAt: String(raw.created_at),
99
- expiresAt: String(raw.expires_at),
125
+ expiresAt,
100
126
  invitedByProfileId: typeof raw.invited_by_profile_id === "string" ? raw.invited_by_profile_id : null,
101
127
  acceptedAt: typeof raw.accepted_at === "string" ? raw.accepted_at : null,
102
128
  acceptedByProfileId: typeof raw.accepted_by_profile_id === "string" ? raw.accepted_by_profile_id : null,
@@ -115,25 +141,6 @@ function isExistingAccountError(error: { message?: string } | null): boolean {
115
141
  return message.includes("already") || message.includes("exists") || message.includes("registered");
116
142
  }
117
143
 
118
- async function syncOrganizationPrimaryContactFromAdmins(
119
- supabase: SupabaseClient,
120
- organizationId: string,
121
- ): Promise<void> {
122
- const { data, error } = await supabase
123
- .from("organization_members")
124
- .select("profile_id")
125
- .eq("organization_id", organizationId)
126
- .eq("role", "admin")
127
- .order("joined_at", { ascending: true })
128
- .limit(1);
129
- if (error) throw new Error(error.message);
130
- const { error: updateError } = await supabase
131
- .from("organizations")
132
- .update({ primary_contact_id: typeof data?.[0]?.profile_id === "string" ? data[0].profile_id : null })
133
- .eq("id", organizationId);
134
- if (updateError) throw new Error(updateError.message);
135
- }
136
-
137
144
  async function findAuthUserIdByEmail(supabase: SupabaseClient, email: string): Promise<string | null> {
138
145
  const target = normalizeEmail(email);
139
146
  const perPage = 1000;
@@ -254,24 +261,33 @@ export async function inviteOrganizationMembers(
254
261
  organizationId: string,
255
262
  invites: OrganizationInviteDraft[],
256
263
  actorProfileId: string,
257
- ): Promise<{ invitations: OrganizationInvitation[]; summary: OrganizationInviteSummary }> {
264
+ options?: {
265
+ ensureCrmContactForProfile?: EnsureCrmContact;
266
+ sendInviteEmail?: SendOrganizationInvite;
267
+ },
268
+ ): Promise<{ invitations: OrganizationInvitation[]; outcomes: OrganizationInviteOutcome[]; summary: OrganizationInviteSummary }> {
258
269
  const normalized = dedupeInviteDrafts(invites);
259
270
  const emptySummary = {
260
271
  pendingInvitations: 0,
272
+ duplicatePendingInvitations: 0,
261
273
  directAssignments: 0,
262
274
  updatedExistingMembers: 0,
263
275
  unchangedExistingMembers: 0,
264
276
  failedEmailDeliveries: 0,
277
+ failedContactLinks: 0,
278
+ failedApiOperations: 0,
265
279
  };
266
- if (normalized.length === 0) return { invitations: [], summary: emptySummary };
280
+ if (normalized.length === 0) return { invitations: [], outcomes: [], summary: emptySummary };
267
281
 
268
282
  const emails = normalized.map((invite) => invite.email);
269
- const [{ data: existingMembers, error: memberError }, { data: profiles, error: profileError }] = await Promise.all([
283
+ const [{ data: existingMembers, error: memberError }, { data: profiles, error: profileError }, { data: existingInvitations, error: invitationError }] = await Promise.all([
270
284
  supabase.from("organization_members").select("profile_id, role, profile:profiles!organization_members_profile_id_fkey(email)").eq("organization_id", organizationId),
271
285
  supabase.from("profiles").select("id, email").in("email", emails),
286
+ supabase.from("organization_invitations").select("id, invited_email, role, status, expires_at").eq("organization_id", organizationId).in("invited_email", emails),
272
287
  ]);
273
288
  if (memberError) throw new Error(memberError.message);
274
289
  if (profileError) throw new Error(profileError.message);
290
+ if (invitationError) throw new Error(invitationError.message);
275
291
  const { data: organization, error: organizationError } = await supabase
276
292
  .from("organizations")
277
293
  .select("name")
@@ -294,23 +310,64 @@ export async function inviteOrganizationMembers(
294
310
  profileByEmail.set(normalizeEmail(profile.email), profile.id);
295
311
  }
296
312
  }
313
+ const pendingInvitationByEmail = new Map<string, { id: string; role: OrganizationMemberRole }>();
314
+ for (const invitation of existingInvitations ?? []) {
315
+ if (
316
+ invitation.status !== "pending"
317
+ || typeof invitation.id !== "string"
318
+ || typeof invitation.invited_email !== "string"
319
+ || isInvitationExpired(String(invitation.expires_at))
320
+ ) continue;
321
+ pendingInvitationByEmail.set(normalizeEmail(invitation.invited_email), {
322
+ id: invitation.id,
323
+ role: invitation.role === "admin" ? "admin" : "member",
324
+ });
325
+ }
297
326
 
298
327
  const pendingRows: Array<Record<string, unknown>> = [];
299
328
  const resolvedProfilesByEmail = new Map<string, string>();
329
+ const outcomes: OrganizationInviteOutcome[] = [];
300
330
  let directAssignments = 0;
301
331
  let updatedExistingMembers = 0;
302
332
  let unchangedExistingMembers = 0;
303
333
  for (const invite of normalized) {
304
334
  const member = memberByEmail.get(invite.email);
305
335
  if (member) {
306
- resolvedProfilesByEmail.set(invite.email, member.profileId);
307
- if (member.role === invite.role) unchangedExistingMembers += 1;
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
+ }
308
349
  else {
309
350
  const { error } = await supabase.from("organization_members").update({ role: invite.role })
310
351
  .eq("organization_id", organizationId).eq("profile_id", member.profileId);
311
- if (error) throw new Error(error.message);
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
+ }
312
367
  updatedExistingMembers += 1;
368
+ outcomes.push({ email: invite.email, role: invite.role, status: "membership_updated", profileId: member.profileId });
313
369
  }
370
+ resolvedProfilesByEmail.set(invite.email, member.profileId);
314
371
  continue;
315
372
  }
316
373
  const profileId = profileByEmail.get(invite.email);
@@ -320,9 +377,29 @@ export async function inviteOrganizationMembers(
320
377
  profile_id: profileId,
321
378
  role: invite.role,
322
379
  }, { onConflict: "organization_id,profile_id" });
323
- if (error) throw new Error(error.message);
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,
387
+ });
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
+ }
394
+ }
324
395
  resolvedProfilesByEmail.set(invite.email, profileId);
325
396
  directAssignments += 1;
397
+ outcomes.push({ email: invite.email, role: invite.role, status: "immediate_access", profileId });
398
+ continue;
399
+ }
400
+ const pendingInvitation = pendingInvitationByEmail.get(invite.email);
401
+ if (pendingInvitation) {
402
+ outcomes.push({ email: invite.email, role: pendingInvitation.role, status: "duplicate_pending", invitationId: pendingInvitation.id });
326
403
  continue;
327
404
  }
328
405
  pendingRows.push({
@@ -340,7 +417,7 @@ export async function inviteOrganizationMembers(
340
417
  }
341
418
 
342
419
  if (directAssignments > 0 || updatedExistingMembers > 0) {
343
- await syncOrganizationPrimaryContactFromAdmins(supabase, organizationId);
420
+ try { await syncOrganizationPrimaryContactFromAdmins(supabase, organizationId); } catch { /* Retry on the next idempotent member operation. */ }
344
421
  }
345
422
  if (resolvedProfilesByEmail.size > 0) {
346
423
  const acceptedAt = new Date().toISOString();
@@ -350,7 +427,8 @@ export async function inviteOrganizationMembers(
350
427
  accepted_at: acceptedAt,
351
428
  accepted_by_profile_id: profileId,
352
429
  }).eq("organization_id", organizationId).eq("invited_email", email).eq("status", "pending");
353
- if (error) throw new Error(error.message);
430
+ // Access is already effective. Leave a pending row for the next idempotent call to reconcile.
431
+ if (error) return;
354
432
  }));
355
433
  }
356
434
  if (pendingRows.length > 0) {
@@ -358,17 +436,29 @@ export async function inviteOrganizationMembers(
358
436
  onConflict: "organization_id,invited_email",
359
437
  ignoreDuplicates: false,
360
438
  });
361
- if (error) throw new Error(error.message);
362
- const { data: inserted, error: selectError } = await supabase
439
+ if (error) {
440
+ outcomes.push(...pendingRows.map((row) => ({
441
+ email: String(row.invited_email), role: row.role === "admin" ? "admin" as const : "member" as const,
442
+ status: "api_failed" as const, message: "Não foi possível criar o convite.", failureKind: "invitation" as const,
443
+ })));
444
+ }
445
+ const { data: inserted, error: selectError } = error ? { data: [], error: null } : await supabase
363
446
  .from("organization_invitations")
364
447
  .select("id, invited_email, role, expires_at")
365
448
  .eq("organization_id", organizationId)
366
449
  .in("invited_email", pendingRows.map((row) => String(row.invited_email)))
367
450
  .eq("status", "pending");
368
- if (selectError) throw new Error(selectError.message);
451
+ if (selectError) {
452
+ outcomes.push(...pendingRows.map((row) => ({
453
+ email: String(row.invited_email), role: row.role === "admin" ? "admin" as const : "member" as const,
454
+ status: "api_failed" as const, message: "Não foi possível confirmar o convite criado.", failureKind: "invitation" as const,
455
+ })));
456
+ }
369
457
  const deliveries = await Promise.all((inserted ?? []).map(async (invitation) => ({
370
458
  id: String(invitation.id),
371
- delivered: await sendOrganizationInviteEmail({
459
+ email: normalizeEmail(String(invitation.invited_email)),
460
+ role: invitation.role === "admin" ? "admin" as const : "member" as const,
461
+ delivered: await (options?.sendInviteEmail ?? sendOrganizationInviteEmail)({
372
462
  invitationId: String(invitation.id),
373
463
  organizationName,
374
464
  invitedEmail: String(invitation.invited_email),
@@ -383,23 +473,86 @@ export async function inviteOrganizationMembers(
383
473
  .delete()
384
474
  .in("id", failedIds)
385
475
  .eq("status", "pending");
386
- if (deleteError) throw new Error(deleteError.message);
387
- throw new Error(ORGANIZATION_INVITE_EMAIL_DELIVERY_ERROR);
476
+ // Preserve the email failure outcome. A remaining row is safe to revoke/retry and stays visible as pending.
477
+ void deleteError;
478
+ }
479
+ for (const delivery of deliveries) {
480
+ outcomes.push({
481
+ email: delivery.email,
482
+ role: delivery.role,
483
+ status: delivery.delivered ? "pending_invitation" : "email_failed",
484
+ invitationId: delivery.delivered ? delivery.id : undefined,
485
+ message: delivery.delivered ? undefined : ORGANIZATION_INVITE_EMAIL_DELIVERY_ERROR,
486
+ });
388
487
  }
389
488
  }
390
489
 
490
+ const failedEmailDeliveries = outcomes.filter((outcome) => outcome.status === "email_failed").length;
491
+ const failedContactLinks = outcomes.filter((outcome) => outcome.status === "api_failed" && outcome.failureKind === "crm_link").length;
492
+ const failedApiOperations = outcomes.filter((outcome) => outcome.status === "api_failed").length;
493
+ let invitations: OrganizationInvitation[] = [];
494
+ try {
495
+ invitations = await listOrganizationInvitations(supabase, organizationId, { status: "pending" });
496
+ } catch (error) {
497
+ console.error("[organizations.invite-members.refresh]", error);
498
+ }
499
+
391
500
  return {
392
- invitations: await listOrganizationInvitations(supabase, organizationId, { status: "pending" }),
501
+ invitations,
502
+ outcomes,
393
503
  summary: {
394
- pendingInvitations: pendingRows.length,
504
+ pendingInvitations: outcomes.filter((outcome) => outcome.status === "pending_invitation").length,
505
+ duplicatePendingInvitations: outcomes.filter((outcome) => outcome.status === "duplicate_pending").length,
395
506
  directAssignments,
396
507
  updatedExistingMembers,
397
508
  unchangedExistingMembers,
398
- failedEmailDeliveries: 0,
509
+ failedEmailDeliveries,
510
+ failedContactLinks,
511
+ failedApiOperations,
399
512
  },
400
513
  };
401
514
  }
402
515
 
516
+ export async function resendOrganizationInvitation(
517
+ supabase: SupabaseClient,
518
+ organizationId: string,
519
+ invitationId: string,
520
+ options?: { sendInviteEmail?: SendOrganizationInvite },
521
+ ): Promise<OrganizationInvitation> {
522
+ const { data, error } = await supabase
523
+ .from("organization_invitations")
524
+ .select("id, organization_id, invited_email, role, status, invited_by_profile_id, accepted_at, accepted_by_profile_id, accepted_contact_id, revoked_at, expires_at, created_at, organizations(name)")
525
+ .eq("id", invitationId)
526
+ .eq("organization_id", organizationId)
527
+ .maybeSingle();
528
+ if (error) throw new Error(error.message);
529
+ if (!data || data.status !== "pending") throw new Error("Convite pendente não encontrado.");
530
+ if (isInvitationExpired(String(data.expires_at))) {
531
+ const { error: expireError } = await supabase
532
+ .from("organization_invitations")
533
+ .update({ status: "expired" })
534
+ .eq("id", invitationId)
535
+ .eq("status", "pending");
536
+ if (expireError) throw new Error(expireError.message);
537
+ throw new Error("Este convite expirou.");
538
+ }
539
+ const organizationRaw = Array.isArray(data.organizations) ? data.organizations[0] ?? null : data.organizations;
540
+ const organizationName = organizationRaw && typeof organizationRaw === "object" && typeof organizationRaw.name === "string"
541
+ ? organizationRaw.name
542
+ : "Organização";
543
+ const delivered = await (options?.sendInviteEmail ?? sendOrganizationInviteEmail)({
544
+ invitationId,
545
+ organizationName,
546
+ invitedEmail: String(data.invited_email),
547
+ role: data.role === "admin" ? "admin" : "member",
548
+ expiresAt: String(data.expires_at),
549
+ });
550
+ if (!delivered) {
551
+ throw new Error("Não foi possível reenviar o email de convite. O convite pendente foi mantido.");
552
+ }
553
+ return normalizeInvitation(data as Record<string, unknown>);
554
+ }
555
+
403
556
  export async function revokeOrganizationInvitation(
404
557
  supabase: SupabaseClient,
405
558
  organizationId: string,