@brightweblabs/module-orgs 0.5.7 → 0.6.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 +4 -4
- package/src/data.ts +58 -0
- package/src/handlers.ts +17 -1
- package/src/http.ts +61 -2
- package/src/index.ts +4 -0
- package/src/invitations.ts +18 -2
- package/src/invite-email.ts +36 -15
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.6.0",
|
|
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.15.
|
|
29
|
-
"@brightweblabs/core-auth": "0.10.
|
|
28
|
+
"@brightweblabs/app-shell": "0.15.8",
|
|
29
|
+
"@brightweblabs/core-auth": "0.10.10",
|
|
30
30
|
"@brightweblabs/infra": "0.7.0",
|
|
31
|
-
"@brightweblabs/ui": "1.5.
|
|
31
|
+
"@brightweblabs/ui": "1.5.2"
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
|
34
34
|
"react": "^19.0.0",
|
package/src/data.ts
CHANGED
|
@@ -288,3 +288,61 @@ export async function setOrganizationMemberRole(
|
|
|
288
288
|
if (data.role !== "admin" && data.role !== "member") throw new Error("Invalid organization member role.");
|
|
289
289
|
return data as OrganizationMember;
|
|
290
290
|
}
|
|
291
|
+
|
|
292
|
+
async function requireSafeOrganizationMemberMutation(
|
|
293
|
+
supabase: SupabaseClient,
|
|
294
|
+
organizationId: string,
|
|
295
|
+
profileId: string,
|
|
296
|
+
): Promise<OrganizationMember> {
|
|
297
|
+
const { data: member, error } = await supabase
|
|
298
|
+
.from("organization_members")
|
|
299
|
+
.select("id, organization_id, profile_id, role, joined_at")
|
|
300
|
+
.eq("organization_id", organizationId)
|
|
301
|
+
.eq("profile_id", profileId)
|
|
302
|
+
.maybeSingle<OrganizationMember>();
|
|
303
|
+
if (error) throw new Error(error.message);
|
|
304
|
+
if (!member) throw new Error("Membro da organização não encontrado.");
|
|
305
|
+
return member;
|
|
306
|
+
}
|
|
307
|
+
|
|
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
|
+
export async function updateOrganizationMemberRole(
|
|
324
|
+
supabase: SupabaseClient,
|
|
325
|
+
organizationId: string,
|
|
326
|
+
profileId: string,
|
|
327
|
+
role: OrganizationMemberRole,
|
|
328
|
+
): Promise<OrganizationMember> {
|
|
329
|
+
const member = await requireSafeOrganizationMemberMutation(supabase, organizationId, profileId);
|
|
330
|
+
if (member.role === role) return member;
|
|
331
|
+
if (role === "member") await assertOrganizationKeepsAdmin(supabase, organizationId, member);
|
|
332
|
+
return setOrganizationMemberRole(supabase, organizationId, profileId, role);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export async function removeOrganizationMember(
|
|
336
|
+
supabase: SupabaseClient,
|
|
337
|
+
organizationId: string,
|
|
338
|
+
profileId: string,
|
|
339
|
+
): Promise<void> {
|
|
340
|
+
const member = await requireSafeOrganizationMemberMutation(supabase, organizationId, profileId);
|
|
341
|
+
await assertOrganizationKeepsAdmin(supabase, organizationId, member);
|
|
342
|
+
const { error } = await supabase
|
|
343
|
+
.from("organization_members")
|
|
344
|
+
.delete()
|
|
345
|
+
.eq("organization_id", organizationId)
|
|
346
|
+
.eq("profile_id", profileId);
|
|
347
|
+
if (error) throw new Error(error.message);
|
|
348
|
+
}
|
package/src/handlers.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { requireOrganizationManageAccess, requireOrganizationsStaffAccess } from "./access";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
createOrganization,
|
|
4
|
+
deleteOrganization,
|
|
5
|
+
removeOrganizationMember,
|
|
6
|
+
updateOrganization,
|
|
7
|
+
updateOrganizationMemberRole,
|
|
8
|
+
} from "./data";
|
|
3
9
|
import {
|
|
4
10
|
inviteOrganizationMembers,
|
|
5
11
|
logOrganizationActivity,
|
|
@@ -11,6 +17,7 @@ import {
|
|
|
11
17
|
createOrganizationInvitationDeleteHandler,
|
|
12
18
|
createOrganizationDeleteHandler,
|
|
13
19
|
createOrganizationInvitationsHandler,
|
|
20
|
+
createOrganizationMemberMutationHandlers,
|
|
14
21
|
createOrganizationPatchHandler,
|
|
15
22
|
createOrganizationsPostHandler,
|
|
16
23
|
} from "./http";
|
|
@@ -43,3 +50,12 @@ export const handleOrganizationInvitationsGetRequest = invitationHandlers.GET;
|
|
|
43
50
|
export const handleOrganizationInvitationsPostRequest = invitationHandlers.POST;
|
|
44
51
|
export const handleOrganizationInvitationDeleteRequest =
|
|
45
52
|
createOrganizationInvitationDeleteHandler(invitationDependencies);
|
|
53
|
+
|
|
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;
|
package/src/http.ts
CHANGED
|
@@ -200,15 +200,16 @@ export function createOrganizationDeleteHandler(dependencies: OrganizationWriteD
|
|
|
200
200
|
|
|
201
201
|
export function createOrganizationInvitationsHandler(dependencies: OrganizationInvitationDependencies) {
|
|
202
202
|
return {
|
|
203
|
-
GET: async (
|
|
203
|
+
GET: async (request: Request, context: { params: Promise<{ id: string }> }): Promise<Response> => {
|
|
204
204
|
const { id } = await context.params;
|
|
205
205
|
if (!id) return json({ error: "id é obrigatório." }, { status: 400 });
|
|
206
206
|
const access = await dependencies.getManageAccess(id);
|
|
207
207
|
if (!access.ok) return json({ error: access.error }, { status: access.status });
|
|
208
208
|
try {
|
|
209
|
+
const includeHistory = new URL(request.url).searchParams.get("status") === "all";
|
|
209
210
|
const [members, invitations] = await Promise.all([
|
|
210
211
|
dependencies.listMembers(access.serviceSupabase, id),
|
|
211
|
-
dependencies.listInvitations(access.serviceSupabase, id, { status: "pending" }),
|
|
212
|
+
dependencies.listInvitations(access.serviceSupabase, id, includeHistory ? undefined : { status: "pending" }),
|
|
212
213
|
]);
|
|
213
214
|
return json({ data: { members, invitations } });
|
|
214
215
|
} catch (error) {
|
|
@@ -254,6 +255,64 @@ export function createOrganizationInvitationsHandler(dependencies: OrganizationI
|
|
|
254
255
|
};
|
|
255
256
|
}
|
|
256
257
|
|
|
258
|
+
type OrganizationMemberMutationDependencies = {
|
|
259
|
+
getManageAccess: OrganizationInvitationDependencies["getManageAccess"];
|
|
260
|
+
updateMemberRole: (
|
|
261
|
+
supabase: SupabaseClient,
|
|
262
|
+
organizationId: string,
|
|
263
|
+
profileId: string,
|
|
264
|
+
role: "admin" | "member",
|
|
265
|
+
) => Promise<unknown>;
|
|
266
|
+
removeMember: (supabase: SupabaseClient, organizationId: string, profileId: string) => Promise<void>;
|
|
267
|
+
logActivity: OrganizationInvitationDependencies["logActivity"];
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
export function createOrganizationMemberMutationHandlers(dependencies: OrganizationMemberMutationDependencies) {
|
|
271
|
+
return {
|
|
272
|
+
PATCH: async (request: Request, context: { params: Promise<{ id: string; profileId: string }> }): Promise<Response> => {
|
|
273
|
+
const { id, profileId } = await context.params;
|
|
274
|
+
if (!id || !profileId) return json({ error: "id e profileId são obrigatórios." }, { status: 400 });
|
|
275
|
+
const access = await dependencies.getManageAccess(id);
|
|
276
|
+
if (!access.ok) return json({ error: access.error }, { status: access.status });
|
|
277
|
+
const body = await readJsonObject(request);
|
|
278
|
+
const role = body?.role === "admin" ? "admin" : body?.role === "member" ? "member" : null;
|
|
279
|
+
if (!role) return json({ error: "Função inválida." }, { status: 400 });
|
|
280
|
+
try {
|
|
281
|
+
const member = await dependencies.updateMemberRole(access.serviceSupabase, id, profileId, role);
|
|
282
|
+
await dependencies.logActivity(access.serviceSupabase, {
|
|
283
|
+
actorProfileId: access.profileId,
|
|
284
|
+
organizationId: id,
|
|
285
|
+
eventType: "crm_organization_member_role_updated",
|
|
286
|
+
summary: "Função de membro da organização atualizada.",
|
|
287
|
+
payload: { organization_id: id, profile_id: profileId, role },
|
|
288
|
+
});
|
|
289
|
+
return json({ data: { member } });
|
|
290
|
+
} catch (error) {
|
|
291
|
+
return organizationError(error);
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
DELETE: async (_request: Request, context: { params: Promise<{ id: string; profileId: string }> }): Promise<Response> => {
|
|
295
|
+
const { id, profileId } = await context.params;
|
|
296
|
+
if (!id || !profileId) return json({ error: "id e profileId são obrigatórios." }, { status: 400 });
|
|
297
|
+
const access = await dependencies.getManageAccess(id);
|
|
298
|
+
if (!access.ok) return json({ error: access.error }, { status: access.status });
|
|
299
|
+
try {
|
|
300
|
+
await dependencies.removeMember(access.serviceSupabase, id, profileId);
|
|
301
|
+
await dependencies.logActivity(access.serviceSupabase, {
|
|
302
|
+
actorProfileId: access.profileId,
|
|
303
|
+
organizationId: id,
|
|
304
|
+
eventType: "crm_organization_member_removed",
|
|
305
|
+
summary: "Acesso à organização removido.",
|
|
306
|
+
payload: { organization_id: id, profile_id: profileId },
|
|
307
|
+
});
|
|
308
|
+
return json({ data: { success: true } });
|
|
309
|
+
} catch (error) {
|
|
310
|
+
return organizationError(error);
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
257
316
|
export function createOrganizationInvitationDeleteHandler(dependencies: OrganizationInvitationDependencies) {
|
|
258
317
|
return async function handleOrganizationInvitationDeleteRequest(
|
|
259
318
|
_request: Request,
|
package/src/index.ts
CHANGED
|
@@ -5,7 +5,9 @@ export {
|
|
|
5
5
|
deleteOrganization,
|
|
6
6
|
listOrganizationMembers,
|
|
7
7
|
listOrganizations,
|
|
8
|
+
removeOrganizationMember,
|
|
8
9
|
setOrganizationMemberRole,
|
|
10
|
+
updateOrganizationMemberRole,
|
|
9
11
|
updateOrganization,
|
|
10
12
|
type CreateOrganizationInput,
|
|
11
13
|
type Organization,
|
|
@@ -21,6 +23,8 @@ export {
|
|
|
21
23
|
handleOrganizationDeleteRequest,
|
|
22
24
|
handleOrganizationInvitationsGetRequest,
|
|
23
25
|
handleOrganizationInvitationsPostRequest,
|
|
26
|
+
handleOrganizationMemberDeleteRequest,
|
|
27
|
+
handleOrganizationMemberPatchRequest,
|
|
24
28
|
handleOrganizationPatchRequest,
|
|
25
29
|
handleOrganizationsPostRequest,
|
|
26
30
|
} from "./handlers";
|
package/src/invitations.ts
CHANGED
|
@@ -55,7 +55,7 @@ export type OrganizationMemberView = {
|
|
|
55
55
|
|
|
56
56
|
export type EnsureCrmContact = (
|
|
57
57
|
profileId: string,
|
|
58
|
-
options: { source: string; serviceClient: SupabaseClient },
|
|
58
|
+
options: { source: string; organizationId?: string | null; serviceClient: SupabaseClient },
|
|
59
59
|
) => Promise<{ success: boolean; contactId?: string; error?: string }>;
|
|
60
60
|
|
|
61
61
|
export async function logOrganizationActivity(
|
|
@@ -296,12 +296,14 @@ export async function inviteOrganizationMembers(
|
|
|
296
296
|
}
|
|
297
297
|
|
|
298
298
|
const pendingRows: Array<Record<string, unknown>> = [];
|
|
299
|
+
const resolvedProfilesByEmail = new Map<string, string>();
|
|
299
300
|
let directAssignments = 0;
|
|
300
301
|
let updatedExistingMembers = 0;
|
|
301
302
|
let unchangedExistingMembers = 0;
|
|
302
303
|
for (const invite of normalized) {
|
|
303
304
|
const member = memberByEmail.get(invite.email);
|
|
304
305
|
if (member) {
|
|
306
|
+
resolvedProfilesByEmail.set(invite.email, member.profileId);
|
|
305
307
|
if (member.role === invite.role) unchangedExistingMembers += 1;
|
|
306
308
|
else {
|
|
307
309
|
const { error } = await supabase.from("organization_members").update({ role: invite.role })
|
|
@@ -319,6 +321,7 @@ export async function inviteOrganizationMembers(
|
|
|
319
321
|
role: invite.role,
|
|
320
322
|
}, { onConflict: "organization_id,profile_id" });
|
|
321
323
|
if (error) throw new Error(error.message);
|
|
324
|
+
resolvedProfilesByEmail.set(invite.email, profileId);
|
|
322
325
|
directAssignments += 1;
|
|
323
326
|
continue;
|
|
324
327
|
}
|
|
@@ -339,6 +342,17 @@ export async function inviteOrganizationMembers(
|
|
|
339
342
|
if (directAssignments > 0 || updatedExistingMembers > 0) {
|
|
340
343
|
await syncOrganizationPrimaryContactFromAdmins(supabase, organizationId);
|
|
341
344
|
}
|
|
345
|
+
if (resolvedProfilesByEmail.size > 0) {
|
|
346
|
+
const acceptedAt = new Date().toISOString();
|
|
347
|
+
await Promise.all(Array.from(resolvedProfilesByEmail, async ([email, profileId]) => {
|
|
348
|
+
const { error } = await supabase.from("organization_invitations").update({
|
|
349
|
+
status: "accepted",
|
|
350
|
+
accepted_at: acceptedAt,
|
|
351
|
+
accepted_by_profile_id: profileId,
|
|
352
|
+
}).eq("organization_id", organizationId).eq("invited_email", email).eq("status", "pending");
|
|
353
|
+
if (error) throw new Error(error.message);
|
|
354
|
+
}));
|
|
355
|
+
}
|
|
342
356
|
if (pendingRows.length > 0) {
|
|
343
357
|
const { error } = await supabase.from("organization_invitations").upsert(pendingRows, {
|
|
344
358
|
onConflict: "organization_id,invited_email",
|
|
@@ -347,7 +361,7 @@ export async function inviteOrganizationMembers(
|
|
|
347
361
|
if (error) throw new Error(error.message);
|
|
348
362
|
const { data: inserted, error: selectError } = await supabase
|
|
349
363
|
.from("organization_invitations")
|
|
350
|
-
.select("id, invited_email, role")
|
|
364
|
+
.select("id, invited_email, role, expires_at")
|
|
351
365
|
.eq("organization_id", organizationId)
|
|
352
366
|
.in("invited_email", pendingRows.map((row) => String(row.invited_email)))
|
|
353
367
|
.eq("status", "pending");
|
|
@@ -359,6 +373,7 @@ export async function inviteOrganizationMembers(
|
|
|
359
373
|
organizationName,
|
|
360
374
|
invitedEmail: String(invitation.invited_email),
|
|
361
375
|
role: invitation.role === "admin" ? "admin" : "member",
|
|
376
|
+
expiresAt: typeof invitation.expires_at === "string" ? invitation.expires_at : undefined,
|
|
362
377
|
}),
|
|
363
378
|
})));
|
|
364
379
|
const failedIds = deliveries.filter((result) => !result.delivered).map((result) => result.id);
|
|
@@ -426,6 +441,7 @@ export async function acceptOrganizationInvitation(
|
|
|
426
441
|
|
|
427
442
|
const acceptedContact = await params.ensureCrmContactForProfile(params.profileId, {
|
|
428
443
|
source: "organization_invitation_accept",
|
|
444
|
+
organizationId: invitation.organization_id,
|
|
429
445
|
serviceClient: supabase,
|
|
430
446
|
});
|
|
431
447
|
if (!acceptedContact.success) {
|
package/src/invite-email.ts
CHANGED
|
@@ -6,6 +6,11 @@ import {
|
|
|
6
6
|
ResendConfigError,
|
|
7
7
|
} from "@brightweblabs/infra/server";
|
|
8
8
|
import { getAuthBaseUrl } from "@brightweblabs/core-auth/shared";
|
|
9
|
+
import {
|
|
10
|
+
buildInvitationEmail,
|
|
11
|
+
emailBrandNameFromSender,
|
|
12
|
+
transactionalEmailPaletteFromEnv,
|
|
13
|
+
} from "@brightweblabs/ui/email/invitation";
|
|
9
14
|
import type { OrganizationMemberRole } from "./data";
|
|
10
15
|
|
|
11
16
|
type SendOrganizationInviteEmailParams = {
|
|
@@ -13,17 +18,9 @@ type SendOrganizationInviteEmailParams = {
|
|
|
13
18
|
organizationName: string;
|
|
14
19
|
invitedEmail: string;
|
|
15
20
|
role: OrganizationMemberRole;
|
|
21
|
+
expiresAt?: string;
|
|
16
22
|
};
|
|
17
23
|
|
|
18
|
-
function escapeHtml(value: string): string {
|
|
19
|
-
return value
|
|
20
|
-
.replace(/&/g, "&")
|
|
21
|
-
.replace(/</g, "<")
|
|
22
|
-
.replace(/>/g, ">")
|
|
23
|
-
.replace(/"/g, """)
|
|
24
|
-
.replace(/'/g, "'");
|
|
25
|
-
}
|
|
26
|
-
|
|
27
24
|
function buildSignupUrl(invitationId: string): string {
|
|
28
25
|
return new URL(`invite/${invitationId}`, `${getAuthBaseUrl()}/`).toString();
|
|
29
26
|
}
|
|
@@ -34,17 +31,41 @@ export async function sendOrganizationInviteEmail(
|
|
|
34
31
|
try {
|
|
35
32
|
const signupUrl = buildSignupUrl(params.invitationId);
|
|
36
33
|
const roleLabel = params.role === "admin" ? "Administrador" : "Membro";
|
|
37
|
-
const
|
|
38
|
-
const
|
|
34
|
+
const sender = getTransactionalSender();
|
|
35
|
+
const brandName = process.env.EMAIL_BRAND_NAME?.trim() || emailBrandNameFromSender(sender);
|
|
36
|
+
const logoPath = process.env.EMAIL_BRAND_LOGO_URL?.trim();
|
|
37
|
+
const logoUrl = logoPath ? new URL(logoPath, `${getAuthBaseUrl()}/`).toString() : null;
|
|
38
|
+
const expiryDate = params.expiresAt ? new Date(params.expiresAt) : null;
|
|
39
|
+
const expiresLabel = expiryDate && !Number.isNaN(expiryDate.getTime())
|
|
40
|
+
? new Intl.DateTimeFormat("pt-PT", { dateStyle: "long" }).format(expiryDate)
|
|
41
|
+
: null;
|
|
42
|
+
const email = buildInvitationEmail({
|
|
43
|
+
brandName,
|
|
44
|
+
logoUrl,
|
|
45
|
+
logoAlt: brandName,
|
|
46
|
+
preheader: `Foi convidado para colaborar com ${params.organizationName}.`,
|
|
47
|
+
eyebrow: "Convite para uma organização",
|
|
48
|
+
title: "Há uma equipa à sua espera",
|
|
49
|
+
introduction: `Aceite o convite para colaborar com ${params.organizationName} através do ${brandName}.`,
|
|
50
|
+
details: [
|
|
51
|
+
{ label: "Organização", value: params.organizationName },
|
|
52
|
+
{ label: "Nível de acesso", value: roleLabel },
|
|
53
|
+
],
|
|
54
|
+
actionLabel: "Aceitar convite",
|
|
55
|
+
actionUrl: signupUrl,
|
|
56
|
+
expiresLabel,
|
|
57
|
+
recipientEmail: params.invitedEmail,
|
|
58
|
+
palette: transactionalEmailPaletteFromEnv(process.env),
|
|
59
|
+
});
|
|
39
60
|
|
|
40
61
|
await resendApiRequest<{ id?: string }>("/emails", {
|
|
41
62
|
method: "POST",
|
|
42
63
|
body: JSON.stringify({
|
|
43
|
-
from:
|
|
64
|
+
from: sender,
|
|
44
65
|
to: [params.invitedEmail],
|
|
45
|
-
subject:
|
|
46
|
-
html:
|
|
47
|
-
text:
|
|
66
|
+
subject: `${brandName}: convite para ${params.organizationName}`,
|
|
67
|
+
html: email.html,
|
|
68
|
+
text: email.text,
|
|
48
69
|
tags: [
|
|
49
70
|
{ name: "flow", value: "organization_invite" },
|
|
50
71
|
{ name: "org_role", value: params.role },
|