@brightweblabs/module-orgs 0.2.3 → 0.3.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.
@@ -12,7 +12,8 @@
12
12
  { "name": "orgs.organizations.read", "since": "0.1.0" },
13
13
  { "name": "orgs.organizations.write", "since": "0.1.0" },
14
14
  { "name": "orgs.membership.read", "since": "0.1.0" },
15
- { "name": "orgs.membership.admin", "since": "0.1.0" }
15
+ { "name": "orgs.membership.admin", "since": "0.1.0" },
16
+ { "name": "orgs.invitations.admin", "since": "0.3.0" }
16
17
  ],
17
18
  "consumes": []
18
19
  },
@@ -20,7 +21,8 @@
20
21
  "orgs.organizations.read": { "specifier": ".", "symbol": "listOrganizations" },
21
22
  "orgs.organizations.write": { "specifier": ".", "symbol": "createOrganization" },
22
23
  "orgs.membership.read": { "specifier": ".", "symbol": "listOrganizationMembers" },
23
- "orgs.membership.admin": { "specifier": ".", "symbol": "setOrganizationMemberRole" }
24
+ "orgs.membership.admin": { "specifier": ".", "symbol": "setOrganizationMemberRole" },
25
+ "orgs.invitations.admin": { "specifier": ".", "symbol": "inviteOrganizationMembers" }
24
26
  },
25
27
  "events": { "emits": [], "consumes": [] },
26
28
  "permissions": {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@brightweblabs/module-orgs",
3
3
  "private": false,
4
- "version": "0.2.3",
4
+ "version": "0.3.1",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
7
7
  "files": [
@@ -21,7 +21,10 @@
21
21
  "./registration": "./src/registration.ts"
22
22
  },
23
23
  "dependencies": {
24
- "@supabase/supabase-js": "^2.105.3",
25
- "@brightweblabs/app-shell": "0.5.0"
24
+ "@supabase/supabase-js": "^2.110.8",
25
+ "server-only": "^0.0.1",
26
+ "@brightweblabs/core-auth": "0.5.0",
27
+ "@brightweblabs/app-shell": "0.6.0",
28
+ "@brightweblabs/infra": "0.3.2"
26
29
  }
27
30
  }
package/src/access.ts ADDED
@@ -0,0 +1,67 @@
1
+ import type { User } from "@supabase/supabase-js";
2
+ import { requireServerUserAccess } from "@brightweblabs/core-auth/server";
3
+ import { requireServiceRoleClient } from "@brightweblabs/infra/server";
4
+ import type { GlobalRole } from "@brightweblabs/core-auth/shared";
5
+
6
+ type OrganizationBaseAccess = {
7
+ serviceSupabase: ReturnType<typeof requireServiceRoleClient>;
8
+ user: User;
9
+ role: GlobalRole;
10
+ profileId: string;
11
+ };
12
+
13
+ type OrganizationAccessError = {
14
+ ok: false;
15
+ status: number;
16
+ error: string;
17
+ };
18
+
19
+ async function resolveBaseOrganizationAccess(): Promise<OrganizationBaseAccess | OrganizationAccessError> {
20
+ const access = await requireServerUserAccess();
21
+ if (!access.ok) {
22
+ return access.status === 409
23
+ ? { ok: false, status: 403, error: "Perfil não encontrado." }
24
+ : access;
25
+ }
26
+ if (!access.role) return { ok: false, status: 403, error: "Acesso proibido." };
27
+
28
+ return {
29
+ serviceSupabase: requireServiceRoleClient(),
30
+ user: access.user,
31
+ role: access.role,
32
+ profileId: access.profileId,
33
+ };
34
+ }
35
+
36
+ function isAccessError(
37
+ access: OrganizationBaseAccess | OrganizationAccessError,
38
+ ): access is OrganizationAccessError {
39
+ return "ok" in access && access.ok === false;
40
+ }
41
+
42
+ export async function requireOrganizationsStaffAccess() {
43
+ const base = await resolveBaseOrganizationAccess();
44
+ if (isAccessError(base)) return base;
45
+ if (base.role !== "admin" && base.role !== "staff") {
46
+ return { ok: false, status: 403, error: "Acesso proibido." } as const;
47
+ }
48
+ return { ok: true, ...base } as const;
49
+ }
50
+
51
+ export async function requireOrganizationManageAccess(organizationId: string) {
52
+ const base = await resolveBaseOrganizationAccess();
53
+ if (isAccessError(base)) return base;
54
+ if (base.role === "admin" || base.role === "staff") {
55
+ return { ok: true, ...base, isOrgMember: true, isOrgAdmin: true } as const;
56
+ }
57
+
58
+ const { data, error } = await base.serviceSupabase
59
+ .from("organization_members")
60
+ .select("role")
61
+ .eq("organization_id", organizationId)
62
+ .eq("profile_id", base.profileId)
63
+ .maybeSingle<{ role: string }>();
64
+ if (error) return { ok: false, status: 500, error: error.message } as const;
65
+ if (data?.role !== "admin") return { ok: false, status: 403, error: "Acesso proibido." } as const;
66
+ return { ok: true, ...base, isOrgMember: true, isOrgAdmin: true } as const;
67
+ }
package/src/data.ts CHANGED
@@ -16,6 +16,8 @@ export type Organization = {
16
16
  website_url: string | null;
17
17
  address: string | null;
18
18
  taxIdentifierValue: string | null;
19
+ taxIdentifierKind: string | null;
20
+ taxIdentifierCountryCode: string | null;
19
21
  primary_contact_id: string | null;
20
22
  primary_contact?: OrganizationPrimaryContact | null;
21
23
  created_at: string;
@@ -42,6 +44,10 @@ export type CreateOrganizationInput = {
42
44
  budgetRange?: string | null;
43
45
  websiteUrl?: string | null;
44
46
  address?: string | null;
47
+ addressLine1?: string | null;
48
+ addressLine2?: string | null;
49
+ zipCode?: string | null;
50
+ country?: string | null;
45
51
  taxIdentifierValue?: string | null;
46
52
  primaryContactId?: string | null;
47
53
  };
@@ -67,6 +73,8 @@ type RawOrganization = {
67
73
  website_url: string | null;
68
74
  address: string | null;
69
75
  tax_identifier_value?: string | null;
76
+ tax_identifier_kind?: string | null;
77
+ tax_identifier_country_code?: string | null;
70
78
  primary_contact_id: string | null;
71
79
  primary_contact?: OrganizationPrimaryContact | OrganizationPrimaryContact[] | null;
72
80
  created_at: string;
@@ -74,6 +82,7 @@ type RawOrganization = {
74
82
 
75
83
  export const ORGANIZATIONS_DEFAULT_PAGE_SIZE = 20;
76
84
  export const ORGANIZATIONS_MAX_PAGE_SIZE = 100;
85
+ const ORGANIZATION_ADDRESS_SEPARATOR = " · ";
77
86
 
78
87
  function normalizePage(page: number | undefined, fallback: number) {
79
88
  return Number.isFinite(page) && (page ?? 0) > 0 ? Math.floor(page as number) : fallback;
@@ -95,12 +104,41 @@ function normalizeOrganization(raw: RawOrganization): Organization {
95
104
  website_url: raw.website_url,
96
105
  address: raw.address,
97
106
  taxIdentifierValue: typeof raw.tax_identifier_value === "string" ? raw.tax_identifier_value : null,
107
+ taxIdentifierKind: typeof raw.tax_identifier_kind === "string" ? raw.tax_identifier_kind : null,
108
+ taxIdentifierCountryCode: typeof raw.tax_identifier_country_code === "string" ? raw.tax_identifier_country_code : null,
98
109
  primary_contact_id: raw.primary_contact_id,
99
110
  primary_contact: Array.isArray(primaryContact) ? primaryContact[0] ?? null : primaryContact ?? null,
100
111
  created_at: raw.created_at,
101
112
  };
102
113
  }
103
114
 
115
+ function buildAddress(input: CreateOrganizationInput): string | null {
116
+ const addressLine1 = input.addressLine1 === undefined ? input.address : input.addressLine1;
117
+ const composed = [addressLine1, input.addressLine2, input.zipCode, input.country]
118
+ .map((part) => part?.trim() ?? "")
119
+ .filter(Boolean)
120
+ .join(ORGANIZATION_ADDRESS_SEPARATOR);
121
+ return composed || null;
122
+ }
123
+
124
+ function organizationWritePayload(input: CreateOrganizationInput) {
125
+ const taxIdentifierValue = input.taxIdentifierValue?.replace(/\D/g, "") || null;
126
+ return {
127
+ name: input.name.trim(),
128
+ industry: input.industry?.trim() || null,
129
+ company_size: input.companySize?.trim() || null,
130
+ budget_range: input.budgetRange?.trim() || null,
131
+ website_url: input.websiteUrl?.trim() || null,
132
+ address: buildAddress(input),
133
+ tax_identifier_value: taxIdentifierValue,
134
+ tax_identifier_kind: taxIdentifierValue ? "vat" : null,
135
+ tax_identifier_country_code: taxIdentifierValue ? "PT" : null,
136
+ };
137
+ }
138
+
139
+ const ORGANIZATION_SELECT =
140
+ "id, name, industry, company_size, budget_range, website_url, address, tax_identifier_value, tax_identifier_kind, tax_identifier_country_code, primary_contact_id, created_at, primary_contact:profiles!organizations_primary_contact_id_fkey(id, first_name, last_name, email)";
141
+
104
142
  export async function listOrganizations(
105
143
  supabase: SupabaseClient,
106
144
  params: OrganizationsListParams = {},
@@ -114,7 +152,7 @@ export async function listOrganizations(
114
152
  let query = supabase
115
153
  .from("organizations")
116
154
  .select(
117
- "id, name, industry, company_size, budget_range, website_url, address, tax_identifier_value, primary_contact_id, created_at, primary_contact:profiles!organizations_primary_contact_id_fkey(id, first_name, last_name, email)",
155
+ ORGANIZATION_SELECT,
118
156
  { count: "exact" },
119
157
  )
120
158
  .order("created_at", { ascending: false })
@@ -147,18 +185,10 @@ export async function createOrganization(
147
185
  const { data, error } = await supabase
148
186
  .from("organizations")
149
187
  .insert({
150
- name,
151
- industry: input.industry?.trim() || null,
152
- company_size: input.companySize?.trim() || null,
153
- budget_range: input.budgetRange?.trim() || null,
154
- website_url: input.websiteUrl?.trim() || null,
155
- address: input.address?.trim() || null,
156
- tax_identifier_value: input.taxIdentifierValue?.trim() || null,
188
+ ...organizationWritePayload(input),
157
189
  primary_contact_id: input.primaryContactId || null,
158
190
  })
159
- .select(
160
- "id, name, industry, company_size, budget_range, website_url, address, tax_identifier_value, primary_contact_id, created_at, primary_contact:profiles!organizations_primary_contact_id_fkey(id, first_name, last_name, email)",
161
- )
191
+ .select(ORGANIZATION_SELECT)
162
192
  .single();
163
193
 
164
194
  if (error) throw new Error(error.message);
@@ -173,22 +203,17 @@ export async function updateOrganization(
173
203
  const name = input.name.trim();
174
204
  if (!name) throw new Error("Organization name is required.");
175
205
 
206
+ const payload: ReturnType<typeof organizationWritePayload> & { primary_contact_id?: string | null } =
207
+ organizationWritePayload(input);
208
+ if (input.primaryContactId !== undefined) {
209
+ payload.primary_contact_id = input.primaryContactId || null;
210
+ }
211
+
176
212
  const { data, error } = await supabase
177
213
  .from("organizations")
178
- .update({
179
- name,
180
- industry: input.industry?.trim() || null,
181
- company_size: input.companySize?.trim() || null,
182
- budget_range: input.budgetRange?.trim() || null,
183
- website_url: input.websiteUrl?.trim() || null,
184
- address: input.address?.trim() || null,
185
- tax_identifier_value: input.taxIdentifierValue?.trim() || null,
186
- primary_contact_id: input.primaryContactId || null,
187
- })
214
+ .update(payload)
188
215
  .eq("id", organizationId)
189
- .select(
190
- "id, name, industry, company_size, budget_range, website_url, address, tax_identifier_value, primary_contact_id, created_at, primary_contact:profiles!organizations_primary_contact_id_fkey(id, first_name, last_name, email)",
191
- )
216
+ .select(ORGANIZATION_SELECT)
192
217
  .single();
193
218
 
194
219
  if (error) throw new Error(error.message);
@@ -0,0 +1,42 @@
1
+ import { requireOrganizationManageAccess, requireOrganizationsStaffAccess } from "./access";
2
+ import { createOrganization, updateOrganization } from "./data";
3
+ import {
4
+ inviteOrganizationMembers,
5
+ logOrganizationActivity,
6
+ listOrganizationInvitations,
7
+ listOrganizationMemberViews,
8
+ revokeOrganizationInvitation,
9
+ } from "./invitations";
10
+ import {
11
+ createOrganizationInvitationDeleteHandler,
12
+ createOrganizationInvitationsHandler,
13
+ createOrganizationPatchHandler,
14
+ createOrganizationsPostHandler,
15
+ } from "./http";
16
+
17
+ const writeDependencies = {
18
+ getCreateAccess: requireOrganizationsStaffAccess,
19
+ getManageAccess: requireOrganizationManageAccess,
20
+ createOrganization,
21
+ updateOrganization,
22
+ inviteMembers: inviteOrganizationMembers,
23
+ logActivity: logOrganizationActivity,
24
+ };
25
+
26
+ const invitationDependencies = {
27
+ getManageAccess: requireOrganizationManageAccess,
28
+ inviteMembers: inviteOrganizationMembers,
29
+ listInvitations: listOrganizationInvitations,
30
+ listMembers: listOrganizationMemberViews,
31
+ revokeInvitation: revokeOrganizationInvitation,
32
+ logActivity: logOrganizationActivity,
33
+ };
34
+
35
+ export const handleOrganizationsPostRequest = createOrganizationsPostHandler(writeDependencies);
36
+ export const handleOrganizationPatchRequest = createOrganizationPatchHandler(writeDependencies);
37
+
38
+ const invitationHandlers = createOrganizationInvitationsHandler(invitationDependencies);
39
+ export const handleOrganizationInvitationsGetRequest = invitationHandlers.GET;
40
+ export const handleOrganizationInvitationsPostRequest = invitationHandlers.POST;
41
+ export const handleOrganizationInvitationDeleteRequest =
42
+ createOrganizationInvitationDeleteHandler(invitationDependencies);
package/src/http.ts ADDED
@@ -0,0 +1,248 @@
1
+ import type { SupabaseClient } from "@supabase/supabase-js";
2
+ import type { CreateOrganizationInput, OrganizationMemberRole } from "./data";
3
+ import type {
4
+ OrganizationInviteDraft,
5
+ inviteOrganizationMembers,
6
+ logOrganizationActivity,
7
+ listOrganizationInvitations,
8
+ listOrganizationMemberViews,
9
+ revokeOrganizationInvitation,
10
+ } from "./invitations";
11
+
12
+ export function json(body: unknown, init?: ResponseInit) {
13
+ return new Response(JSON.stringify(body), {
14
+ ...init,
15
+ headers: { "content-type": "application/json; charset=utf-8", ...(init?.headers ?? {}) },
16
+ });
17
+ }
18
+
19
+ type OrganizationAccess =
20
+ | { ok: true; serviceSupabase: SupabaseClient; profileId: string }
21
+ | { ok: false; status: number; error: string };
22
+
23
+ type OrganizationWriteDependencies = {
24
+ getCreateAccess(): Promise<OrganizationAccess>;
25
+ getManageAccess(organizationId: string): Promise<OrganizationAccess>;
26
+ createOrganization: (supabase: SupabaseClient, input: CreateOrganizationInput) => Promise<unknown>;
27
+ updateOrganization: (supabase: SupabaseClient, id: string, input: CreateOrganizationInput) => Promise<unknown>;
28
+ inviteMembers: typeof inviteOrganizationMembers;
29
+ logActivity: typeof logOrganizationActivity;
30
+ };
31
+
32
+ type OrganizationInvitationDependencies = {
33
+ getManageAccess(organizationId: string): Promise<OrganizationAccess>;
34
+ inviteMembers: typeof inviteOrganizationMembers;
35
+ listInvitations: typeof listOrganizationInvitations;
36
+ listMembers: typeof listOrganizationMemberViews;
37
+ revokeInvitation: typeof revokeOrganizationInvitation;
38
+ logActivity: typeof logOrganizationActivity;
39
+ };
40
+
41
+ async function readJsonObject(request: Request): Promise<Record<string, unknown> | null> {
42
+ try {
43
+ const value = await request.json();
44
+ return value && typeof value === "object" && !Array.isArray(value)
45
+ ? value as Record<string, unknown>
46
+ : null;
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ function readOptionalString(body: Record<string, unknown>, key: string): string | null | undefined {
53
+ if (!Object.hasOwn(body, key)) return undefined;
54
+ const value = body[key];
55
+ if (typeof value !== "string") return null;
56
+ return value.trim() || null;
57
+ }
58
+
59
+ function parseInvites(raw: unknown): OrganizationInviteDraft[] {
60
+ if (!Array.isArray(raw)) return [];
61
+ return raw.flatMap((item) => {
62
+ if (!item || typeof item !== "object") return [];
63
+ const record = item as Record<string, unknown>;
64
+ const email = typeof record.email === "string" ? record.email.trim().toLowerCase() : "";
65
+ const role: OrganizationMemberRole = record.role === "admin" ? "admin" : "member";
66
+ return email && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? [{ email, role }] : [];
67
+ });
68
+ }
69
+
70
+ function parseOrganizationInput(body: Record<string, unknown>): CreateOrganizationInput {
71
+ return {
72
+ name: typeof body.name === "string" ? body.name.trim() : "",
73
+ primaryContactId: readOptionalString(body, "primaryContactId"),
74
+ industry: readOptionalString(body, "industry"),
75
+ companySize: readOptionalString(body, "companySize"),
76
+ budgetRange: readOptionalString(body, "budgetRange"),
77
+ websiteUrl: readOptionalString(body, "websiteUrl"),
78
+ addressLine1: readOptionalString(body, "addressLine1"),
79
+ addressLine2: readOptionalString(body, "addressLine2"),
80
+ zipCode: readOptionalString(body, "zipCode"),
81
+ country: readOptionalString(body, "country"),
82
+ taxIdentifierValue:
83
+ readOptionalString(body, "taxIdentifierValue") ?? readOptionalString(body, "nif"),
84
+ };
85
+ }
86
+
87
+ function organizationError(error: unknown): Response {
88
+ const message = error instanceof Error ? error.message : "";
89
+ if (message.startsWith("Não foi possível enviar o email de convite.")) {
90
+ return json({ error: message }, { status: 502 });
91
+ }
92
+ return json({ error: message || "Erro interno do servidor." }, { status: 500 });
93
+ }
94
+
95
+ export function createOrganizationsPostHandler(dependencies: OrganizationWriteDependencies) {
96
+ return async function handleOrganizationsPostRequest(request: Request): Promise<Response> {
97
+ const access = await dependencies.getCreateAccess();
98
+ if (!access.ok) return json({ error: access.error }, { status: access.status });
99
+ const body = await readJsonObject(request);
100
+ if (!body) return json({ error: "Payload inválido." }, { status: 400 });
101
+ const input = parseOrganizationInput(body);
102
+ if (!input.name) return json({ error: "name é obrigatório." }, { status: 400 });
103
+ try {
104
+ const organization = await dependencies.createOrganization(access.serviceSupabase, input) as { id: string };
105
+ const result = await dependencies.inviteMembers(
106
+ access.serviceSupabase,
107
+ organization.id,
108
+ parseInvites(body.invitations),
109
+ access.profileId,
110
+ );
111
+ await dependencies.logActivity(access.serviceSupabase, {
112
+ actorProfileId: access.profileId,
113
+ organizationId: organization.id,
114
+ eventType: "crm_organization_created",
115
+ summary: "Organização CRM criada.",
116
+ payload: {
117
+ organization_id: organization.id,
118
+ pending_invitations: result.summary.pendingInvitations,
119
+ direct_assignments: result.summary.directAssignments,
120
+ },
121
+ });
122
+ return json({ data: { organization, invitations: result.invitations, inviteSummary: result.summary } }, { status: 201 });
123
+ } catch (error) {
124
+ return organizationError(error);
125
+ }
126
+ };
127
+ }
128
+
129
+ export function createOrganizationPatchHandler(dependencies: OrganizationWriteDependencies) {
130
+ return async function handleOrganizationPatchRequest(
131
+ request: Request,
132
+ context: { params: Promise<{ id: string }> },
133
+ ): Promise<Response> {
134
+ const { id } = await context.params;
135
+ if (!id) return json({ error: "id é obrigatório." }, { status: 400 });
136
+ const access = await dependencies.getManageAccess(id);
137
+ if (!access.ok) return json({ error: access.error }, { status: access.status });
138
+ const body = await readJsonObject(request);
139
+ if (!body) return json({ error: "Payload inválido." }, { status: 400 });
140
+ const input = parseOrganizationInput(body);
141
+ if (!input.name) return json({ error: "name é obrigatório." }, { status: 400 });
142
+ try {
143
+ const organization = await dependencies.updateOrganization(access.serviceSupabase, id, input) as { id: string };
144
+ const result = await dependencies.inviteMembers(
145
+ access.serviceSupabase,
146
+ id,
147
+ parseInvites(body.invitations),
148
+ access.profileId,
149
+ );
150
+ await dependencies.logActivity(access.serviceSupabase, {
151
+ actorProfileId: access.profileId,
152
+ organizationId: id,
153
+ eventType: "crm_organization_updated",
154
+ summary: "Organização CRM atualizada.",
155
+ payload: {
156
+ organization_id: id,
157
+ pending_invitations: result.summary.pendingInvitations,
158
+ direct_assignments: result.summary.directAssignments,
159
+ updated_existing_members: result.summary.updatedExistingMembers,
160
+ },
161
+ });
162
+ return json({ data: { organization, invitations: result.invitations, inviteSummary: result.summary } });
163
+ } catch (error) {
164
+ return organizationError(error);
165
+ }
166
+ };
167
+ }
168
+
169
+ export function createOrganizationInvitationsHandler(dependencies: OrganizationInvitationDependencies) {
170
+ return {
171
+ GET: async (_request: Request, context: { params: Promise<{ id: string }> }): Promise<Response> => {
172
+ const { id } = await context.params;
173
+ if (!id) return json({ error: "id é obrigatório." }, { status: 400 });
174
+ const access = await dependencies.getManageAccess(id);
175
+ if (!access.ok) return json({ error: access.error }, { status: access.status });
176
+ try {
177
+ const [members, invitations] = await Promise.all([
178
+ dependencies.listMembers(access.serviceSupabase, id),
179
+ dependencies.listInvitations(access.serviceSupabase, id, { status: "pending" }),
180
+ ]);
181
+ return json({ data: { members, invitations } });
182
+ } catch (error) {
183
+ return organizationError(error);
184
+ }
185
+ },
186
+ POST: async (request: Request, context: { params: Promise<{ id: string }> }): Promise<Response> => {
187
+ const { id } = await context.params;
188
+ if (!id) return json({ error: "id é obrigatório." }, { status: 400 });
189
+ const access = await dependencies.getManageAccess(id);
190
+ if (!access.ok) return json({ error: access.error }, { status: access.status });
191
+ const body = await readJsonObject(request);
192
+ if (!body) return json({ error: "Payload inválido." }, { status: 400 });
193
+ const invitations = parseInvites(body.invitations);
194
+ if (invitations.length === 0) {
195
+ return json({ error: "Nenhum convite válido foi enviado." }, { status: 400 });
196
+ }
197
+ try {
198
+ const result = await dependencies.inviteMembers(
199
+ access.serviceSupabase,
200
+ id,
201
+ invitations,
202
+ access.profileId,
203
+ );
204
+ await dependencies.logActivity(access.serviceSupabase, {
205
+ actorProfileId: access.profileId,
206
+ organizationId: id,
207
+ eventType: "crm_organization_invitations_created",
208
+ summary: "Convites da organização CRM criados.",
209
+ payload: {
210
+ organization_id: id,
211
+ invitations: result.invitations.length,
212
+ pending_invitations: result.summary.pendingInvitations,
213
+ direct_assignments: result.summary.directAssignments,
214
+ updated_existing_members: result.summary.updatedExistingMembers,
215
+ },
216
+ });
217
+ return json({ data: { invitations: result.invitations, inviteSummary: result.summary } }, { status: 201 });
218
+ } catch (error) {
219
+ return organizationError(error);
220
+ }
221
+ },
222
+ };
223
+ }
224
+
225
+ export function createOrganizationInvitationDeleteHandler(dependencies: OrganizationInvitationDependencies) {
226
+ return async function handleOrganizationInvitationDeleteRequest(
227
+ _request: Request,
228
+ context: { params: Promise<{ id: string; invitationId: string }> },
229
+ ): Promise<Response> {
230
+ const { id, invitationId } = await context.params;
231
+ if (!id || !invitationId) return json({ error: "id e invitationId são obrigatórios." }, { status: 400 });
232
+ const access = await dependencies.getManageAccess(id);
233
+ if (!access.ok) return json({ error: access.error }, { status: access.status });
234
+ try {
235
+ await dependencies.revokeInvitation(access.serviceSupabase, id, invitationId);
236
+ await dependencies.logActivity(access.serviceSupabase, {
237
+ actorProfileId: access.profileId,
238
+ organizationId: id,
239
+ eventType: "crm_organization_invitation_revoked",
240
+ summary: "Convite da organização CRM revogado.",
241
+ payload: { organization_id: id, invitation_id: invitationId },
242
+ });
243
+ return json({ data: { success: true } });
244
+ } catch (error) {
245
+ return organizationError(error);
246
+ }
247
+ };
248
+ }
package/src/index.ts CHANGED
@@ -15,3 +15,27 @@ export {
15
15
  type OrganizationsListResult,
16
16
  type UpdateOrganizationInput,
17
17
  } from "./data";
18
+ export {
19
+ handleOrganizationInvitationDeleteRequest,
20
+ handleOrganizationInvitationsGetRequest,
21
+ handleOrganizationInvitationsPostRequest,
22
+ handleOrganizationPatchRequest,
23
+ handleOrganizationsPostRequest,
24
+ } from "./handlers";
25
+ export {
26
+ ORGANIZATION_INVITE_EMAIL_DELIVERY_ERROR,
27
+ acceptOrganizationInvitation,
28
+ getOrganizationInvitationDetails,
29
+ inviteOrganizationMembers,
30
+ listOrganizationInvitations,
31
+ listOrganizationMemberViews,
32
+ logOrganizationActivity,
33
+ registerUserFromOrganizationInvitation,
34
+ revokeOrganizationInvitation,
35
+ type EnsureCrmContact,
36
+ type OrganizationInvitation,
37
+ type OrganizationInvitationDetails,
38
+ type OrganizationInviteDraft,
39
+ type OrganizationInviteSummary,
40
+ type OrganizationMemberView,
41
+ } from "./invitations";
@@ -0,0 +1,517 @@
1
+ import type { SupabaseClient } from "@supabase/supabase-js";
2
+ import type { OrganizationMemberRole } from "./data";
3
+ import { sendOrganizationInviteEmail } from "./invite-email";
4
+
5
+ const INVITE_EXPIRY_DAYS = 14;
6
+ export const ORGANIZATION_INVITE_EMAIL_DELIVERY_ERROR =
7
+ "Não foi possível enviar o email de convite. O convite não foi guardado. Verifique a configuração do Resend.";
8
+
9
+ export type OrganizationInviteDraft = {
10
+ email: string;
11
+ role: OrganizationMemberRole;
12
+ };
13
+
14
+ export type OrganizationInvitation = {
15
+ id: string;
16
+ organizationId: string;
17
+ email: string;
18
+ role: OrganizationMemberRole;
19
+ status: "pending" | "accepted" | "revoked" | "expired";
20
+ createdAt: string;
21
+ expiresAt: string;
22
+ invitedByProfileId: string | null;
23
+ acceptedAt: string | null;
24
+ acceptedByProfileId: string | null;
25
+ acceptedContactId: string | null;
26
+ revokedAt: string | null;
27
+ };
28
+
29
+ export type OrganizationInvitationDetails = {
30
+ id: string;
31
+ organizationId: string;
32
+ organizationName: string;
33
+ invitedEmail: string;
34
+ role: OrganizationMemberRole;
35
+ status: OrganizationInvitation["status"];
36
+ expiresAt: string;
37
+ };
38
+
39
+ export type OrganizationInviteSummary = {
40
+ pendingInvitations: number;
41
+ directAssignments: number;
42
+ updatedExistingMembers: number;
43
+ unchangedExistingMembers: number;
44
+ failedEmailDeliveries: number;
45
+ };
46
+
47
+ export type OrganizationMemberView = {
48
+ id: string;
49
+ profileId: string;
50
+ role: OrganizationMemberRole;
51
+ joinedAt: string;
52
+ label: string;
53
+ email: string | null;
54
+ };
55
+
56
+ export type EnsureCrmContact = (
57
+ profileId: string,
58
+ options: { source: string; serviceClient: SupabaseClient },
59
+ ) => Promise<{ success: boolean; contactId?: string; error?: string }>;
60
+
61
+ export async function logOrganizationActivity(
62
+ supabase: SupabaseClient,
63
+ input: {
64
+ actorProfileId: string | null;
65
+ organizationId: string;
66
+ eventType: string;
67
+ summary: string;
68
+ payload: Record<string, unknown>;
69
+ },
70
+ ): Promise<void> {
71
+ const { error } = await supabase.rpc("log_app_activity_event", {
72
+ p_domain: "crm",
73
+ p_event_type: input.eventType,
74
+ p_entity_table: "organizations",
75
+ p_entity_id: input.organizationId,
76
+ p_summary: input.summary,
77
+ p_payload: input.payload,
78
+ p_actor_profile_id: input.actorProfileId,
79
+ });
80
+ if (error) console.error("Error logging organization activity event:", error);
81
+ }
82
+
83
+ function normalizeEmail(value: string): string {
84
+ return value.trim().toLowerCase();
85
+ }
86
+
87
+ function normalizeStatus(value: unknown): OrganizationInvitation["status"] {
88
+ return value === "accepted" || value === "revoked" || value === "expired" ? value : "pending";
89
+ }
90
+
91
+ function normalizeInvitation(raw: Record<string, unknown>): OrganizationInvitation {
92
+ return {
93
+ id: String(raw.id),
94
+ organizationId: String(raw.organization_id),
95
+ email: typeof raw.invited_email === "string" ? normalizeEmail(raw.invited_email) : "",
96
+ role: raw.role === "admin" ? "admin" : "member",
97
+ status: normalizeStatus(raw.status),
98
+ createdAt: String(raw.created_at),
99
+ expiresAt: String(raw.expires_at),
100
+ invitedByProfileId: typeof raw.invited_by_profile_id === "string" ? raw.invited_by_profile_id : null,
101
+ acceptedAt: typeof raw.accepted_at === "string" ? raw.accepted_at : null,
102
+ acceptedByProfileId: typeof raw.accepted_by_profile_id === "string" ? raw.accepted_by_profile_id : null,
103
+ acceptedContactId: typeof raw.accepted_contact_id === "string" ? raw.accepted_contact_id : null,
104
+ revokedAt: typeof raw.revoked_at === "string" ? raw.revoked_at : null,
105
+ };
106
+ }
107
+
108
+ function isInvitationExpired(expiresAt: string): boolean {
109
+ const date = new Date(expiresAt);
110
+ return Number.isNaN(date.getTime()) || date.getTime() < Date.now();
111
+ }
112
+
113
+ function isExistingAccountError(error: { message?: string } | null): boolean {
114
+ const message = (error?.message ?? "").toLowerCase();
115
+ return message.includes("already") || message.includes("exists") || message.includes("registered");
116
+ }
117
+
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
+ async function findAuthUserIdByEmail(supabase: SupabaseClient, email: string): Promise<string | null> {
138
+ const target = normalizeEmail(email);
139
+ const perPage = 1000;
140
+ for (let page = 1; page <= 20; page += 1) {
141
+ const { data, error } = await supabase.auth.admin.listUsers({ page, perPage });
142
+ if (error) throw new Error(error.message);
143
+ const match = data.users.find((user) => normalizeEmail(user.email ?? "") === target);
144
+ if (match?.id) return match.id;
145
+ if (data.users.length < perPage) return null;
146
+ }
147
+ return null;
148
+ }
149
+
150
+ async function ensureInvitationProfile(
151
+ supabase: SupabaseClient,
152
+ params: { userId: string; email: string; firstName: string; lastName: string },
153
+ ): Promise<{ id: string }> {
154
+ const { error: syncError } = await supabase.rpc("sync_profile_from_auth_identity", {
155
+ p_user_id: params.userId,
156
+ p_email: params.email,
157
+ p_metadata: { first_name: params.firstName || null, last_name: params.lastName || null },
158
+ });
159
+ if (syncError) throw new Error(syncError.message);
160
+ const { data, error } = await supabase
161
+ .from("profiles")
162
+ .select("id")
163
+ .eq("user_id", params.userId)
164
+ .maybeSingle<{ id: string }>();
165
+ if (error) throw new Error(error.message);
166
+ if (!data?.id) throw new Error("Não foi possível criar o perfil.");
167
+ return data;
168
+ }
169
+
170
+ export async function getOrganizationInvitationDetails(
171
+ supabase: SupabaseClient,
172
+ invitationId: string,
173
+ ): Promise<OrganizationInvitationDetails | null> {
174
+ const { data, error } = await supabase
175
+ .from("organization_invitations")
176
+ .select("id, organization_id, invited_email, role, status, expires_at, organizations(name)")
177
+ .eq("id", invitationId)
178
+ .maybeSingle();
179
+ if (error) throw new Error(error.message);
180
+ if (!data) return null;
181
+ const raw = data as unknown as Record<string, unknown>;
182
+ const organizationRaw = raw.organizations;
183
+ const organization = Array.isArray(organizationRaw) ? organizationRaw[0] ?? null : organizationRaw;
184
+ return {
185
+ id: String(raw.id),
186
+ organizationId: String(raw.organization_id),
187
+ organizationName:
188
+ organization && typeof organization === "object" && typeof (organization as { name?: unknown }).name === "string"
189
+ ? (organization as { name: string }).name
190
+ : "Organização",
191
+ invitedEmail: typeof raw.invited_email === "string" ? normalizeEmail(raw.invited_email) : "",
192
+ role: raw.role === "admin" ? "admin" : "member",
193
+ status: normalizeStatus(raw.status),
194
+ expiresAt: String(raw.expires_at),
195
+ };
196
+ }
197
+
198
+ export async function listOrganizationInvitations(
199
+ supabase: SupabaseClient,
200
+ organizationId: string,
201
+ options?: { status?: OrganizationInvitation["status"] },
202
+ ): Promise<OrganizationInvitation[]> {
203
+ let query = supabase
204
+ .from("organization_invitations")
205
+ .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")
206
+ .eq("organization_id", organizationId)
207
+ .order("created_at", { ascending: false });
208
+ if (options?.status) query = query.eq("status", options.status);
209
+ const { data, error } = await query;
210
+ if (error) throw new Error(error.message);
211
+ return ((data ?? []) as Array<Record<string, unknown>>).map(normalizeInvitation);
212
+ }
213
+
214
+ export async function listOrganizationMemberViews(
215
+ supabase: SupabaseClient,
216
+ organizationId: string,
217
+ ): Promise<OrganizationMemberView[]> {
218
+ const { data, error } = await supabase
219
+ .from("organization_members")
220
+ .select("id, profile_id, role, joined_at, profile:profiles!organization_members_profile_id_fkey(first_name, last_name, email)")
221
+ .eq("organization_id", organizationId)
222
+ .order("joined_at", { ascending: true });
223
+ if (error) throw new Error(error.message);
224
+ return (data ?? []).flatMap((row) => {
225
+ if (typeof row.id !== "string" || typeof row.profile_id !== "string") return [];
226
+ const rawProfile = Array.isArray(row.profile) ? row.profile[0] ?? null : row.profile;
227
+ const profile = rawProfile as { first_name?: unknown; last_name?: unknown; email?: unknown } | null;
228
+ const first = typeof profile?.first_name === "string" ? profile.first_name.trim() : "";
229
+ const last = typeof profile?.last_name === "string" ? profile.last_name.trim() : "";
230
+ const email = typeof profile?.email === "string" ? profile.email : null;
231
+ return [{
232
+ id: row.id,
233
+ profileId: row.profile_id,
234
+ role: row.role === "admin" ? "admin" : "member",
235
+ joinedAt: typeof row.joined_at === "string" ? row.joined_at : new Date().toISOString(),
236
+ label: `${first} ${last}`.trim() || email || "Membro",
237
+ email,
238
+ }];
239
+ });
240
+ }
241
+
242
+ function dedupeInviteDrafts(invites: OrganizationInviteDraft[]): OrganizationInviteDraft[] {
243
+ const seen = new Set<string>();
244
+ return invites.flatMap((invite) => {
245
+ const email = normalizeEmail(invite.email);
246
+ if (!email || seen.has(email)) return [];
247
+ seen.add(email);
248
+ return [{ email, role: invite.role === "admin" ? "admin" : "member" }];
249
+ });
250
+ }
251
+
252
+ export async function inviteOrganizationMembers(
253
+ supabase: SupabaseClient,
254
+ organizationId: string,
255
+ invites: OrganizationInviteDraft[],
256
+ actorProfileId: string,
257
+ ): Promise<{ invitations: OrganizationInvitation[]; summary: OrganizationInviteSummary }> {
258
+ const normalized = dedupeInviteDrafts(invites);
259
+ const emptySummary = {
260
+ pendingInvitations: 0,
261
+ directAssignments: 0,
262
+ updatedExistingMembers: 0,
263
+ unchangedExistingMembers: 0,
264
+ failedEmailDeliveries: 0,
265
+ };
266
+ if (normalized.length === 0) return { invitations: [], summary: emptySummary };
267
+
268
+ const emails = normalized.map((invite) => invite.email);
269
+ const [{ data: existingMembers, error: memberError }, { data: profiles, error: profileError }] = await Promise.all([
270
+ supabase.from("organization_members").select("profile_id, role, profile:profiles!organization_members_profile_id_fkey(email)").eq("organization_id", organizationId),
271
+ supabase.from("profiles").select("id, email").in("email", emails),
272
+ ]);
273
+ if (memberError) throw new Error(memberError.message);
274
+ if (profileError) throw new Error(profileError.message);
275
+ const { data: organization, error: organizationError } = await supabase
276
+ .from("organizations")
277
+ .select("name")
278
+ .eq("id", organizationId)
279
+ .maybeSingle<{ name: string | null }>();
280
+ if (organizationError) throw new Error(organizationError.message);
281
+ const organizationName = organization?.name?.trim() || "Organização";
282
+
283
+ const memberByEmail = new Map<string, { profileId: string; role: OrganizationMemberRole }>();
284
+ for (const row of existingMembers ?? []) {
285
+ const rawProfile = Array.isArray(row.profile) ? row.profile[0] ?? null : row.profile;
286
+ const email = rawProfile && typeof rawProfile.email === "string" ? normalizeEmail(rawProfile.email) : "";
287
+ if (email && typeof row.profile_id === "string") {
288
+ memberByEmail.set(email, { profileId: row.profile_id, role: row.role === "admin" ? "admin" : "member" });
289
+ }
290
+ }
291
+ const profileByEmail = new Map<string, string>();
292
+ for (const profile of profiles ?? []) {
293
+ if (typeof profile.id === "string" && typeof profile.email === "string") {
294
+ profileByEmail.set(normalizeEmail(profile.email), profile.id);
295
+ }
296
+ }
297
+
298
+ const pendingRows: Array<Record<string, unknown>> = [];
299
+ let directAssignments = 0;
300
+ let updatedExistingMembers = 0;
301
+ let unchangedExistingMembers = 0;
302
+ for (const invite of normalized) {
303
+ const member = memberByEmail.get(invite.email);
304
+ if (member) {
305
+ if (member.role === invite.role) unchangedExistingMembers += 1;
306
+ else {
307
+ const { error } = await supabase.from("organization_members").update({ role: invite.role })
308
+ .eq("organization_id", organizationId).eq("profile_id", member.profileId);
309
+ if (error) throw new Error(error.message);
310
+ updatedExistingMembers += 1;
311
+ }
312
+ continue;
313
+ }
314
+ const profileId = profileByEmail.get(invite.email);
315
+ if (profileId) {
316
+ const { error } = await supabase.from("organization_members").upsert({
317
+ organization_id: organizationId,
318
+ profile_id: profileId,
319
+ role: invite.role,
320
+ }, { onConflict: "organization_id,profile_id" });
321
+ if (error) throw new Error(error.message);
322
+ directAssignments += 1;
323
+ continue;
324
+ }
325
+ pendingRows.push({
326
+ organization_id: organizationId,
327
+ invited_email: invite.email,
328
+ role: invite.role,
329
+ status: "pending",
330
+ invited_by_profile_id: actorProfileId,
331
+ expires_at: new Date(Date.now() + INVITE_EXPIRY_DAYS * 86_400_000).toISOString(),
332
+ accepted_at: null,
333
+ accepted_by_profile_id: null,
334
+ accepted_contact_id: null,
335
+ revoked_at: null,
336
+ });
337
+ }
338
+
339
+ if (directAssignments > 0 || updatedExistingMembers > 0) {
340
+ await syncOrganizationPrimaryContactFromAdmins(supabase, organizationId);
341
+ }
342
+ if (pendingRows.length > 0) {
343
+ const { error } = await supabase.from("organization_invitations").upsert(pendingRows, {
344
+ onConflict: "organization_id,invited_email",
345
+ ignoreDuplicates: false,
346
+ });
347
+ if (error) throw new Error(error.message);
348
+ const { data: inserted, error: selectError } = await supabase
349
+ .from("organization_invitations")
350
+ .select("id, invited_email, role")
351
+ .eq("organization_id", organizationId)
352
+ .in("invited_email", pendingRows.map((row) => String(row.invited_email)))
353
+ .eq("status", "pending");
354
+ if (selectError) throw new Error(selectError.message);
355
+ const deliveries = await Promise.all((inserted ?? []).map(async (invitation) => ({
356
+ id: String(invitation.id),
357
+ delivered: await sendOrganizationInviteEmail({
358
+ invitationId: String(invitation.id),
359
+ organizationName,
360
+ invitedEmail: String(invitation.invited_email),
361
+ role: invitation.role === "admin" ? "admin" : "member",
362
+ }),
363
+ })));
364
+ const failedIds = deliveries.filter((result) => !result.delivered).map((result) => result.id);
365
+ if (failedIds.length > 0) {
366
+ const { error: deleteError } = await supabase
367
+ .from("organization_invitations")
368
+ .delete()
369
+ .in("id", failedIds)
370
+ .eq("status", "pending");
371
+ if (deleteError) throw new Error(deleteError.message);
372
+ throw new Error(ORGANIZATION_INVITE_EMAIL_DELIVERY_ERROR);
373
+ }
374
+ }
375
+
376
+ return {
377
+ invitations: await listOrganizationInvitations(supabase, organizationId, { status: "pending" }),
378
+ summary: {
379
+ pendingInvitations: pendingRows.length,
380
+ directAssignments,
381
+ updatedExistingMembers,
382
+ unchangedExistingMembers,
383
+ failedEmailDeliveries: 0,
384
+ },
385
+ };
386
+ }
387
+
388
+ export async function revokeOrganizationInvitation(
389
+ supabase: SupabaseClient,
390
+ organizationId: string,
391
+ invitationId: string,
392
+ ): Promise<void> {
393
+ const { error } = await supabase.from("organization_invitations")
394
+ .update({ status: "revoked", revoked_at: new Date().toISOString() })
395
+ .eq("id", invitationId).eq("organization_id", organizationId).eq("status", "pending");
396
+ if (error) throw new Error(error.message);
397
+ }
398
+
399
+ export async function acceptOrganizationInvitation(
400
+ supabase: SupabaseClient,
401
+ params: {
402
+ invitationId: string;
403
+ profileId: string;
404
+ userEmail: string;
405
+ ensureCrmContactForProfile: EnsureCrmContact;
406
+ },
407
+ ): Promise<{ organizationId: string }> {
408
+ const email = normalizeEmail(params.userEmail);
409
+ const { data: invitation, error } = await supabase
410
+ .from("organization_invitations")
411
+ .select("id, organization_id, invited_email, role, status, expires_at")
412
+ .eq("id", params.invitationId)
413
+ .maybeSingle<{ id: string; organization_id: string; invited_email: string; role: string; status: string; expires_at: string }>();
414
+ if (error) throw new Error(error.message);
415
+ if (!invitation?.id) throw new Error("Convite não encontrado.");
416
+ if (normalizeEmail(invitation.invited_email) !== email) throw new Error("Este convite pertence a outro email.");
417
+ if (invitation.status !== "pending") throw new Error("Este convite já não está disponível.");
418
+ if (isInvitationExpired(invitation.expires_at)) {
419
+ const { error: expireError } = await supabase.from("organization_invitations")
420
+ .update({ status: "expired" }).eq("id", invitation.id);
421
+ if (expireError) throw new Error(expireError.message);
422
+ throw new Error("Este convite expirou.");
423
+ }
424
+
425
+ const acceptedContact = await params.ensureCrmContactForProfile(params.profileId, {
426
+ source: "organization_invitation_accept",
427
+ serviceClient: supabase,
428
+ });
429
+ if (!acceptedContact.success) {
430
+ throw new Error(acceptedContact.error ?? "Não foi possível associar o contacto CRM ao convite.");
431
+ }
432
+ const role: OrganizationMemberRole = invitation.role === "admin" ? "admin" : "member";
433
+ const { error: memberError } = await supabase.from("organization_members").upsert({
434
+ organization_id: invitation.organization_id,
435
+ profile_id: params.profileId,
436
+ role,
437
+ }, { onConflict: "organization_id,profile_id" });
438
+ if (memberError) throw new Error(memberError.message);
439
+ if (role === "admin") await syncOrganizationPrimaryContactFromAdmins(supabase, invitation.organization_id);
440
+ const { error: updateError } = await supabase.from("organization_invitations").update({
441
+ status: "accepted",
442
+ accepted_at: new Date().toISOString(),
443
+ accepted_by_profile_id: params.profileId,
444
+ accepted_contact_id: acceptedContact.contactId ?? null,
445
+ }).eq("id", invitation.id);
446
+ if (updateError) throw new Error(updateError.message);
447
+ await logOrganizationActivity(supabase, {
448
+ actorProfileId: params.profileId,
449
+ organizationId: invitation.organization_id,
450
+ eventType: "crm_organization_invitation_accepted",
451
+ summary: "Convite de organização aceite.",
452
+ payload: {
453
+ invitation_id: invitation.id,
454
+ organization_id: invitation.organization_id,
455
+ email,
456
+ },
457
+ });
458
+ return { organizationId: invitation.organization_id };
459
+ }
460
+
461
+ export async function registerUserFromOrganizationInvitation(
462
+ supabase: SupabaseClient,
463
+ params: {
464
+ invitationId: string;
465
+ firstName: string;
466
+ lastName: string;
467
+ password: string;
468
+ ensureCrmContactForProfile: EnsureCrmContact;
469
+ },
470
+ ): Promise<{ email: string; organizationId: string }> {
471
+ const invitation = await getOrganizationInvitationDetails(supabase, params.invitationId);
472
+ if (!invitation) throw new Error("INVITATION_NOT_FOUND");
473
+ if (invitation.status !== "pending") throw new Error("INVITATION_NOT_AVAILABLE");
474
+ if (isInvitationExpired(invitation.expiresAt)) {
475
+ const { error } = await supabase.from("organization_invitations")
476
+ .update({ status: "expired" }).eq("id", invitation.id).eq("status", "pending");
477
+ if (error) throw new Error(error.message);
478
+ throw new Error("INVITATION_EXPIRED");
479
+ }
480
+
481
+ const firstName = params.firstName.trim();
482
+ const lastName = params.lastName.trim();
483
+ const { data, error } = await supabase.auth.admin.createUser({
484
+ email: invitation.invitedEmail,
485
+ password: params.password,
486
+ email_confirm: true,
487
+ user_metadata: { first_name: firstName || null, last_name: lastName || null },
488
+ });
489
+ let userId = data.user?.id ?? null;
490
+ let createdUserId = userId;
491
+ if (error) {
492
+ if (!isExistingAccountError(error)) throw new Error(error.message);
493
+ userId = await findAuthUserIdByEmail(supabase, invitation.invitedEmail);
494
+ if (!userId) throw new Error("ACCOUNT_ALREADY_EXISTS");
495
+ createdUserId = null;
496
+ }
497
+ if (!userId) throw new Error("Não foi possível criar utilizador.");
498
+
499
+ try {
500
+ const profile = await ensureInvitationProfile(supabase, {
501
+ userId,
502
+ email: invitation.invitedEmail,
503
+ firstName,
504
+ lastName,
505
+ });
506
+ await acceptOrganizationInvitation(supabase, {
507
+ invitationId: invitation.id,
508
+ profileId: profile.id,
509
+ userEmail: invitation.invitedEmail,
510
+ ensureCrmContactForProfile: params.ensureCrmContactForProfile,
511
+ });
512
+ } catch (caught) {
513
+ if (createdUserId) await supabase.auth.admin.deleteUser(createdUserId);
514
+ throw caught;
515
+ }
516
+ return { email: invitation.invitedEmail, organizationId: invitation.organizationId };
517
+ }
@@ -0,0 +1,66 @@
1
+ import "server-only";
2
+
3
+ import {
4
+ getTransactionalSender,
5
+ resendApiRequest,
6
+ ResendConfigError,
7
+ } from "@brightweblabs/infra/server";
8
+ import { getAuthBaseUrl } from "@brightweblabs/core-auth/shared";
9
+ import type { OrganizationMemberRole } from "./data";
10
+
11
+ type SendOrganizationInviteEmailParams = {
12
+ invitationId: string;
13
+ organizationName: string;
14
+ invitedEmail: string;
15
+ role: OrganizationMemberRole;
16
+ };
17
+
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
+ function buildSignupUrl(invitationId: string): string {
28
+ return new URL(`invite/${invitationId}`, `${getAuthBaseUrl()}/`).toString();
29
+ }
30
+
31
+ export async function sendOrganizationInviteEmail(
32
+ params: SendOrganizationInviteEmailParams,
33
+ ): Promise<boolean> {
34
+ try {
35
+ const signupUrl = buildSignupUrl(params.invitationId);
36
+ const roleLabel = params.role === "admin" ? "Administrador" : "Membro";
37
+ const safeOrganization = escapeHtml(params.organizationName);
38
+ const safeUrl = escapeHtml(signupUrl);
39
+
40
+ await resendApiRequest<{ id?: string }>("/emails", {
41
+ method: "POST",
42
+ body: JSON.stringify({
43
+ from: getTransactionalSender(),
44
+ 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}`,
48
+ tags: [
49
+ { name: "flow", value: "organization_invite" },
50
+ { name: "org_role", value: params.role },
51
+ ],
52
+ }),
53
+ });
54
+
55
+ return true;
56
+ } catch (error) {
57
+ if (!(error instanceof ResendConfigError)) {
58
+ console.error("Organization invite email failed", {
59
+ invitationId: params.invitationId,
60
+ invitedEmail: params.invitedEmail,
61
+ error,
62
+ });
63
+ }
64
+ return false;
65
+ }
66
+ }