@brightweblabs/module-orgs 0.2.3 → 0.3.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/brightweb.module.json +4 -2
- package/package.json +6 -3
- package/src/access.ts +67 -0
- package/src/data.ts +49 -24
- package/src/handlers.ts +42 -0
- package/src/http.ts +248 -0
- package/src/index.ts +24 -0
- package/src/invitations.ts +517 -0
- package/src/invite-email.ts +66 -0
package/brightweb.module.json
CHANGED
|
@@ -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.
|
|
4
|
+
"version": "0.3.0",
|
|
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.
|
|
25
|
-
"
|
|
24
|
+
"@supabase/supabase-js": "^2.110.8",
|
|
25
|
+
"server-only": "^0.0.1",
|
|
26
|
+
"@brightweblabs/app-shell": "0.6.0",
|
|
27
|
+
"@brightweblabs/core-auth": "0.4.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
|
-
|
|
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
|
-
|
|
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);
|
package/src/handlers.ts
ADDED
|
@@ -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, "&")
|
|
21
|
+
.replace(/</g, "<")
|
|
22
|
+
.replace(/>/g, ">")
|
|
23
|
+
.replace(/"/g, """)
|
|
24
|
+
.replace(/'/g, "'");
|
|
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
|
+
}
|