Packages added: - @bytelyst/referral-client — referral API client + share helpers - @bytelyst/subscription-client — subscription/plan API client + cache - @bytelyst/celebrations — milestone triggers, confetti, positive messages - @bytelyst/gentle-notifications — ND-friendly messaging, forbidden phrases - @bytelyst/accessibility — VoiceOver/TalkBack label generators - @bytelyst/quick-actions — progressive disclosure, smart defaults - @bytelyst/time-references — familiar duration references - @bytelyst/org-client — org/workspace/membership/license API client - @bytelyst/marketplace-client — listing/review/install API client All packages: pure TS, ESM, globalThis.fetch, no Node.js deps. 99 Vitest tests across 9 packages, 79/79 public methods covered. Review fixes applied: - time-references: fix module-level mutable state leak + add clearCustomReferences() - accessibility: fix parameter reassignment in formatDurationForA11y/numberToWords - subscription-client: fix flaky daysRemaining test (ms boundary race)
225 lines
7.5 KiB
TypeScript
225 lines
7.5 KiB
TypeScript
/**
|
|
* Browser/React Native-safe org, workspace, membership, and license client
|
|
* for platform-service.
|
|
*
|
|
* All org routes require admin-only access (super_admin or admin JWT role).
|
|
* No Node.js dependencies — uses globalThis.fetch.
|
|
*/
|
|
|
|
import type {
|
|
LicenseDoc,
|
|
MembershipDoc,
|
|
OrgClient,
|
|
OrgClientConfig,
|
|
OrganizationDoc,
|
|
WorkspaceDoc,
|
|
} from './types.js';
|
|
|
|
function generateRequestId(): string {
|
|
return typeof globalThis.crypto?.randomUUID === 'function'
|
|
? globalThis.crypto.randomUUID()
|
|
: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
}
|
|
|
|
export function createOrgClient(config: OrgClientConfig): OrgClient {
|
|
const { baseUrl, productId, getAccessToken } = config;
|
|
|
|
function headers(): Record<string, string> {
|
|
const h: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
'x-product-id': productId,
|
|
'x-request-id': generateRequestId(),
|
|
};
|
|
const token = getAccessToken();
|
|
if (token) h['Authorization'] = `Bearer ${token}`;
|
|
return h;
|
|
}
|
|
|
|
// ── Organizations ─────────────────────────────────
|
|
|
|
async function listOrgs(query?: { status?: string; limit?: number }): Promise<OrganizationDoc[]> {
|
|
const params = new URLSearchParams();
|
|
if (query?.status) params.set('status', query.status);
|
|
if (query?.limit) params.set('limit', String(query.limit));
|
|
const qs = params.toString();
|
|
const url = qs ? `${baseUrl}/orgs?${qs}` : `${baseUrl}/orgs`;
|
|
const res = await globalThis.fetch(url, { headers: headers() });
|
|
if (!res.ok) throw new Error(`listOrgs failed: ${res.status}`);
|
|
return (await res.json()) as OrganizationDoc[];
|
|
}
|
|
|
|
async function createOrg(input: {
|
|
name: string;
|
|
slug: string;
|
|
ownerUserId?: string;
|
|
}): Promise<OrganizationDoc> {
|
|
const res = await globalThis.fetch(`${baseUrl}/orgs`, {
|
|
method: 'POST',
|
|
headers: headers(),
|
|
body: JSON.stringify({ ...input, productId }),
|
|
});
|
|
if (!res.ok) throw new Error(`createOrg failed: ${res.status}`);
|
|
return (await res.json()) as OrganizationDoc;
|
|
}
|
|
|
|
async function getOrg(id: string): Promise<OrganizationDoc> {
|
|
const res = await globalThis.fetch(`${baseUrl}/orgs/${encodeURIComponent(id)}`, {
|
|
headers: headers(),
|
|
});
|
|
if (!res.ok) throw new Error(`getOrg failed: ${res.status}`);
|
|
return (await res.json()) as OrganizationDoc;
|
|
}
|
|
|
|
async function updateOrg(
|
|
id: string,
|
|
updates: Partial<OrganizationDoc>
|
|
): Promise<OrganizationDoc> {
|
|
const res = await globalThis.fetch(`${baseUrl}/orgs/${encodeURIComponent(id)}`, {
|
|
method: 'PATCH',
|
|
headers: headers(),
|
|
body: JSON.stringify(updates),
|
|
});
|
|
if (!res.ok) throw new Error(`updateOrg failed: ${res.status}`);
|
|
return (await res.json()) as OrganizationDoc;
|
|
}
|
|
|
|
// ── Workspaces ────────────────────────────────────
|
|
|
|
async function listWorkspaces(orgId: string): Promise<WorkspaceDoc[]> {
|
|
const res = await globalThis.fetch(`${baseUrl}/orgs/${encodeURIComponent(orgId)}/workspaces`, {
|
|
headers: headers(),
|
|
});
|
|
if (!res.ok) throw new Error(`listWorkspaces failed: ${res.status}`);
|
|
return (await res.json()) as WorkspaceDoc[];
|
|
}
|
|
|
|
async function createWorkspace(
|
|
orgId: string,
|
|
input: { name: string; slug: string; description?: string }
|
|
): Promise<WorkspaceDoc> {
|
|
const res = await globalThis.fetch(`${baseUrl}/orgs/${encodeURIComponent(orgId)}/workspaces`, {
|
|
method: 'POST',
|
|
headers: headers(),
|
|
body: JSON.stringify(input),
|
|
});
|
|
if (!res.ok) throw new Error(`createWorkspace failed: ${res.status}`);
|
|
return (await res.json()) as WorkspaceDoc;
|
|
}
|
|
|
|
async function updateWorkspace(
|
|
orgId: string,
|
|
workspaceId: string,
|
|
updates: Partial<WorkspaceDoc>
|
|
): Promise<WorkspaceDoc> {
|
|
const res = await globalThis.fetch(
|
|
`${baseUrl}/orgs/${encodeURIComponent(orgId)}/workspaces/${encodeURIComponent(workspaceId)}`,
|
|
{
|
|
method: 'PATCH',
|
|
headers: headers(),
|
|
body: JSON.stringify(updates),
|
|
}
|
|
);
|
|
if (!res.ok) throw new Error(`updateWorkspace failed: ${res.status}`);
|
|
return (await res.json()) as WorkspaceDoc;
|
|
}
|
|
|
|
// ── Memberships ───────────────────────────────────
|
|
|
|
async function listMemberships(
|
|
orgId: string,
|
|
query?: { scope?: string; limit?: number }
|
|
): Promise<MembershipDoc[]> {
|
|
const params = new URLSearchParams();
|
|
if (query?.scope) params.set('scope', query.scope);
|
|
if (query?.limit) params.set('limit', String(query.limit));
|
|
const qs = params.toString();
|
|
const url = qs
|
|
? `${baseUrl}/orgs/${encodeURIComponent(orgId)}/memberships?${qs}`
|
|
: `${baseUrl}/orgs/${encodeURIComponent(orgId)}/memberships`;
|
|
const res = await globalThis.fetch(url, { headers: headers() });
|
|
if (!res.ok) throw new Error(`listMemberships failed: ${res.status}`);
|
|
return (await res.json()) as MembershipDoc[];
|
|
}
|
|
|
|
async function addMember(
|
|
orgId: string,
|
|
input: { userId: string; role?: string; scope?: string; workspaceId?: string }
|
|
): Promise<MembershipDoc> {
|
|
const res = await globalThis.fetch(`${baseUrl}/orgs/${encodeURIComponent(orgId)}/memberships`, {
|
|
method: 'POST',
|
|
headers: headers(),
|
|
body: JSON.stringify(input),
|
|
});
|
|
if (!res.ok) throw new Error(`addMember failed: ${res.status}`);
|
|
return (await res.json()) as MembershipDoc;
|
|
}
|
|
|
|
async function updateMember(
|
|
orgId: string,
|
|
membershipId: string,
|
|
updates: { role?: string; status?: string }
|
|
): Promise<MembershipDoc> {
|
|
const res = await globalThis.fetch(
|
|
`${baseUrl}/orgs/${encodeURIComponent(orgId)}/memberships/${encodeURIComponent(membershipId)}`,
|
|
{
|
|
method: 'PATCH',
|
|
headers: headers(),
|
|
body: JSON.stringify(updates),
|
|
}
|
|
);
|
|
if (!res.ok) throw new Error(`updateMember failed: ${res.status}`);
|
|
return (await res.json()) as MembershipDoc;
|
|
}
|
|
|
|
// ── Licenses ──────────────────────────────────────
|
|
|
|
async function generateLicense(input: {
|
|
userId: string;
|
|
plan: string;
|
|
maxDevices?: number;
|
|
}): Promise<LicenseDoc> {
|
|
const res = await globalThis.fetch(`${baseUrl}/licenses`, {
|
|
method: 'POST',
|
|
headers: headers(),
|
|
body: JSON.stringify({ ...input, productId }),
|
|
});
|
|
if (!res.ok) throw new Error(`generateLicense failed: ${res.status}`);
|
|
return (await res.json()) as LicenseDoc;
|
|
}
|
|
|
|
async function activateLicense(input: { key: string; deviceId: string }): Promise<LicenseDoc> {
|
|
const res = await globalThis.fetch(`${baseUrl}/licenses/activate`, {
|
|
method: 'POST',
|
|
headers: headers(),
|
|
body: JSON.stringify(input),
|
|
});
|
|
if (!res.ok) throw new Error(`activateLicense failed: ${res.status}`);
|
|
return (await res.json()) as LicenseDoc;
|
|
}
|
|
|
|
async function deactivateLicense(input: { key: string; deviceId: string }): Promise<void> {
|
|
const res = await globalThis.fetch(`${baseUrl}/licenses/deactivate`, {
|
|
method: 'POST',
|
|
headers: headers(),
|
|
body: JSON.stringify(input),
|
|
});
|
|
if (!res.ok) throw new Error(`deactivateLicense failed: ${res.status}`);
|
|
}
|
|
|
|
return {
|
|
listOrgs,
|
|
createOrg,
|
|
getOrg,
|
|
updateOrg,
|
|
listWorkspaces,
|
|
createWorkspace,
|
|
updateWorkspace,
|
|
listMemberships,
|
|
addMember,
|
|
updateMember,
|
|
generateLicense,
|
|
activateLicense,
|
|
deactivateLicense,
|
|
};
|
|
}
|