@brightweblabs/module-orgs 0.5.7 → 0.6.1

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.5.7",
4
+ "version": "0.6.1",
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.7",
29
- "@brightweblabs/core-auth": "0.10.9",
28
+ "@brightweblabs/core-auth": "0.10.11",
29
+ "@brightweblabs/app-shell": "0.15.9",
30
30
  "@brightweblabs/infra": "0.7.0",
31
- "@brightweblabs/ui": "1.5.1"
31
+ "@brightweblabs/ui": "1.5.3"
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 { createOrganization, deleteOrganization, updateOrganization } from "./data";
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 (_request: Request, context: { params: Promise<{ id: string }> }): Promise<Response> => {
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";
@@ -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) {
@@ -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, "&amp;")
21
- .replace(/</g, "&lt;")
22
- .replace(/>/g, "&gt;")
23
- .replace(/"/g, "&quot;")
24
- .replace(/'/g, "&#39;");
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 safeOrganization = escapeHtml(params.organizationName);
38
- const safeUrl = escapeHtml(signupUrl);
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: getTransactionalSender(),
64
+ from: sender,
44
65
  to: [params.invitedEmail],
45
- subject: `Convite para a organização ${params.organizationName}`,
46
- html: `<p>Foi convidado(a) para <strong>${safeOrganization}</strong> como ${roleLabel}.</p><p><a href="${safeUrl}">Aceitar convite</a></p>`,
47
- text: `Convite para ${params.organizationName} (${roleLabel})\n\n${signupUrl}`,
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 },