@brightweblabs/module-orgs 0.6.2 → 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 +2 -2
- package/src/data.ts +25 -18
- package/src/handlers.ts +55 -36
- package/src/http.ts +41 -4
- package/src/index.ts +7 -0
- package/src/invitations.ts +193 -40
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brightweblabs/module-orgs",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.7.0",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
7
7
|
"files": [
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"lucide-react": "^1.26.0",
|
|
27
27
|
"server-only": "^0.0.1",
|
|
28
28
|
"@brightweblabs/app-shell": "0.16.0",
|
|
29
|
-
"@brightweblabs/core-auth": "0.
|
|
29
|
+
"@brightweblabs/core-auth": "0.12.0",
|
|
30
30
|
"@brightweblabs/infra": "0.7.0",
|
|
31
31
|
"@brightweblabs/ui": "1.5.4"
|
|
32
32
|
},
|
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
|
-
|
|
332
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const invitationHandlers = createOrganizationInvitationsHandler(invitationDependencies);
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
export const
|
|
61
|
-
export const
|
|
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";
|
package/src/invitations.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
2
|
-
import type
|
|
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:
|
|
123
|
+
status: storedStatus === "pending" && isInvitationExpired(expiresAt) ? "expired" : storedStatus,
|
|
98
124
|
createdAt: String(raw.created_at),
|
|
99
|
-
expiresAt
|
|
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
|
-
|
|
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
|
-
|
|
307
|
-
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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)
|
|
362
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
387
|
-
|
|
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
|
|
501
|
+
invitations,
|
|
502
|
+
outcomes,
|
|
393
503
|
summary: {
|
|
394
|
-
pendingInvitations:
|
|
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
|
|
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,
|