stack/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts
2026-06-22 23:42:17 -07:00

1330 lines
56 KiB
TypeScript

import { KnownErrors, HexclaveAdminInterface } from "@hexclave/shared";
import { getProductionModeErrors } from "@hexclave/shared/dist/helpers/production-mode";
import { InternalApiKeyCreateCrudResponse } from "@hexclave/shared/dist/interface/admin-interface";
import type { AnalyticsClickmapOptions, AnalyticsClickmapResponse, AnalyticsClickmapTokenResponse, MetricsResponse, MetricsUserCounts, UserActivityResponse } from "@hexclave/shared/dist/interface/admin-metrics";
import { AnalyticsQueryOptions, AnalyticsQueryResponse } from "@hexclave/shared/dist/interface/crud/analytics";
import { EmailTemplateCrud } from "@hexclave/shared/dist/interface/crud/email-templates";
import { InternalApiKeysCrud } from "@hexclave/shared/dist/interface/crud/internal-api-keys";
import { ProjectsCrud } from "@hexclave/shared/dist/interface/crud/projects";
import type { AdminGetSessionReplayChunkEventsResponse } from "@hexclave/shared/dist/interface/crud/session-replays";
import type { Transaction, TransactionType } from "@hexclave/shared/dist/interface/crud/transactions";
import type { RestrictedReason } from "@hexclave/shared/dist/schema-fields";
import type { MoneyAmount } from "@hexclave/shared/dist/utils/currency-constants";
import { HexclaveAssertionError, throwErr } from "@hexclave/shared/dist/utils/errors";
import type { Json } from "@hexclave/shared/dist/utils/json";
import { pick, typedEntries, typedValues } from "@hexclave/shared/dist/utils/objects";
import { Result } from "@hexclave/shared/dist/utils/results";
import { useMemo } from "react"; // THIS_LINE_PLATFORM react-like
import { AdminEmailOutbox, AdminSentEmail } from "../..";
import { EmailConfig, hexclaveAppInternalsSymbol } from "../../common";
import { AdminEmailTemplate } from "../../email-templates";
import { InternalApiKey, InternalApiKeyBase, InternalApiKeyBaseCrudRead, InternalApiKeyCreateOptions, InternalApiKeyFirstView, internalApiKeyCreateOptionsToCrud } from "../../internal-api-keys";
import { AdminProjectPermission, AdminProjectPermissionDefinition, AdminProjectPermissionDefinitionCreateOptions, AdminProjectPermissionDefinitionUpdateOptions, AdminTeamPermission, AdminTeamPermissionDefinition, AdminTeamPermissionDefinitionCreateOptions, AdminTeamPermissionDefinitionUpdateOptions, adminProjectPermissionDefinitionCreateOptionsToCrud, adminProjectPermissionDefinitionUpdateOptionsToCrud, adminTeamPermissionDefinitionCreateOptionsToCrud, adminTeamPermissionDefinitionUpdateOptionsToCrud } from "../../permissions";
import { AdminOwnedProject, AdminProject, AdminProjectUpdateOptions, PushConfigOptions, adminProjectUpdateOptionsToCrud } from "../../projects";
import type { AdminSessionReplay, AdminSessionReplayChunk, ListSessionReplayChunksOptions, ListSessionReplayChunksResult, ListSessionReplaysOptions, ListSessionReplaysResult, SessionReplayAllEventsResult } from "../../session-replays";
import { ManagedEmailProviderListItem, ManagedEmailProviderSetupResult, ManagedEmailProviderStatus, EmailOutboxUpdateOptions, StackAdminApp, StackAdminAppConstructorOptions } from "../interfaces/admin-app";
import { clientVersion, createCache, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getDefaultSecretServerKey, getDefaultSuperSecretAdminKey, resolveApiUrls, resolveConstructorOptions } from "./common";
import { _HexclaveServerAppImplIncomplete } from "./server-app-impl";
import { CompleteConfig, EnvironmentConfigOverrideOverride } from "@hexclave/shared/dist/config/schema";
import { branchConfigSourceSchema } from "@hexclave/shared/dist/schema-fields";
import * as yup from "yup";
import { PushedConfigSource } from "../../projects";
import { useAsyncCache } from "./common"; // THIS_LINE_PLATFORM react-like
type BranchConfigSourceApi = yup.InferType<typeof branchConfigSourceSchema>;
/**
* Converts a PushedConfigSource (SDK camelCase) to BranchConfigSourceApi (API snake_case).
*/
function pushedConfigSourceToApi(source: PushedConfigSource): BranchConfigSourceApi {
if (source.type === "pushed-from-github") {
return {
type: "pushed-from-github",
owner: source.owner,
repo: source.repo,
branch: source.branch,
commit_hash: source.commitHash,
config_file_path: source.configFilePath,
workflow_path: source.workflowPath,
};
}
return source;
}
/**
* Converts a BranchConfigSourceApi (API snake_case) to PushedConfigSource (SDK camelCase).
*/
function apiToPushedConfigSource(source: BranchConfigSourceApi): PushedConfigSource {
if (source.type === "pushed-from-github") {
return {
type: "pushed-from-github",
owner: source.owner,
repo: source.repo,
branch: source.branch,
commitHash: source.commit_hash,
configFilePath: source.config_file_path,
workflowPath: source.workflow_path,
};
}
return source;
}
export class _HexclaveAdminAppImplIncomplete<HasTokenStore extends boolean, ProjectId extends string> extends _HexclaveServerAppImplIncomplete<HasTokenStore, ProjectId> implements StackAdminApp<HasTokenStore, ProjectId> {
declare protected _interface: HexclaveAdminInterface;
private readonly _adminProjectCache = createCache(async () => {
return await this._interface.getProject();
});
private readonly _internalApiKeysCache = createCache(async () => {
const res = await this._interface.listInternalApiKeys();
return res;
});
private readonly _adminEmailThemeCache = createCache(async ([id]: [string]) => {
return await this._interface.getEmailTheme(id);
});
private readonly _adminEmailThemesCache = createCache(async () => {
return await this._interface.listEmailThemes();
});
private readonly _adminEmailTemplatesCache = createCache(async () => {
return await this._interface.listInternalEmailTemplates();
});
private readonly _adminEmailDraftsCache = createCache(async () => {
return await this._interface.listInternalEmailDrafts();
});
private readonly _adminTeamPermissionDefinitionsCache = createCache(async () => {
return await this._interface.listTeamPermissionDefinitions();
});
private readonly _adminProjectPermissionDefinitionsCache = createCache(async () => {
return await this._interface.listProjectPermissionDefinitions();
});
private readonly _svixTokenCache = createCache(async () => {
return await this._interface.getSvixToken();
});
// Cache key serializes filters via URLSearchParams (sorted keys) so
// DependenciesMap (identity-keyed per array slot) treats two equal
// filter objects as the same deterministic string entry.
private readonly _metricsCache = createCache(async ([includeAnonymous, filtersKey]: [boolean, string]) => {
const filters = filtersKey ? Object.fromEntries(new URLSearchParams(filtersKey)) : undefined;
return await this._interface.getMetrics(includeAnonymous, filters);
});
private readonly _userActivityCache = createCache(async ([userId]: [string]) => {
return await this._interface.getUserActivity(userId);
});
private readonly _metricsUserCountsCache = createCache(async () => {
return await this._interface.getMetricsUserCounts();
});
private readonly _emailPreviewCache = createCache(async ([themeId, themeTsxSource, templateId, templateTsxSource]: [string | null | false | undefined, string | undefined, string | undefined, string | undefined]) => {
return await this._interface.renderEmailPreview({ themeId, themeTsxSource, templateId, templateTsxSource });
});
private readonly _emailPreviewWithEditableMarkersCache = createCache(async ([themeId, themeTsxSource, templateId, templateTsxSource, editableSource]: [string | null | false | undefined, string | undefined, string | undefined, string | undefined, 'template' | 'theme' | 'both' | undefined]) => {
return await this._interface.renderEmailPreview({ themeId, themeTsxSource, templateId, templateTsxSource, editableMarkers: true, editableSource });
});
private readonly _configOverridesCache = createCache(async () => {
return await this._interface.getConfig();
});
private readonly _stripeAccountInfoCache = createCache(async () => {
try {
return await this._interface.getStripeAccountInfo();
} catch (error: any) {
if (error?.status === 404) {
return null;
}
throw error;
}
});
private readonly _transactionsCache = createCache(async ([cursor, limit, type, customerType, customerId]: [string | undefined, number | undefined, TransactionType | undefined, 'user' | 'team' | 'custom' | undefined, string | undefined]) => {
return await this._interface.listTransactions({ cursor, limit, type, customerType, customerId });
});
constructor(options: StackAdminAppConstructorOptions<HasTokenStore, ProjectId>, extraOptions?: { uniqueIdentifier?: string, checkString?: string, interface?: HexclaveAdminInterface }) {
const resolvedOptions = resolveConstructorOptions(options);
const publishableClientKey = resolvedOptions.publishableClientKey ?? getDefaultPublishableClientKey();
super(resolvedOptions, {
...extraOptions,
interface: extraOptions?.interface ?? (() => {
const apiUrls = resolveApiUrls(resolvedOptions.baseUrl);
return new HexclaveAdminInterface({
getBaseUrl: () => apiUrls()[0],
getApiUrls: apiUrls,
projectId: resolvedOptions.projectId ?? getDefaultProjectId(),
extraRequestHeaders: resolvedOptions.extraRequestHeaders ?? getDefaultExtraRequestHeaders(),
clientVersion,
...resolvedOptions.projectOwnerSession ? {
projectOwnerSession: resolvedOptions.projectOwnerSession,
} : {
...(publishableClientKey ? { publishableClientKey } : {}),
secretServerKey: resolvedOptions.secretServerKey ?? getDefaultSecretServerKey(),
superSecretAdminKey: resolvedOptions.superSecretAdminKey ?? getDefaultSuperSecretAdminKey(),
},
});
})(),
});
}
_adminConfigFromCrud(data: { config_string: string }): CompleteConfig {
return JSON.parse(data.config_string);
}
_adminOwnedProjectFromCrud(data: ProjectsCrud['Admin']['Read'], onRefresh: () => Promise<void>): AdminOwnedProject {
if (this._tokenStoreInit !== null) {
throw new HexclaveAssertionError("Owned apps must always have tokenStore === null — did you not create this project with app._createOwnedApp()?");
}
return {
...this._adminProjectFromCrud(data, onRefresh),
app: this as StackAdminApp<false>,
};
}
_adminProjectFromCrud(data: ProjectsCrud['Admin']['Read'], onRefresh: () => Promise<void>): AdminProject {
if (data.id !== this.projectId) {
throw new HexclaveAssertionError(`The project ID of the provided project JSON (${data.id}) does not match the project ID of the app (${this.projectId})!`);
}
const app = this;
return {
id: data.id,
displayName: data.display_name,
description: data.description,
createdAt: new Date(data.created_at_millis),
isProductionMode: data.is_production_mode,
isDevelopmentEnvironment: data.is_development_environment,
ownerTeamId: data.owner_team_id,
onboardingStatus: data.onboarding_status,
logoUrl: data.logo_url,
logoFullUrl: data.logo_full_url,
logoDarkModeUrl: data.logo_dark_mode_url,
logoFullDarkModeUrl: data.logo_full_dark_mode_url,
pushedConfigError: data.pushed_config_error == null ? null : {
message: data.pushed_config_error.message,
},
configWarnings: data.config_warnings.map((warning) => ({
message: warning.message,
})),
config: {
signUpEnabled: data.config.sign_up_enabled,
credentialEnabled: data.config.credential_enabled,
magicLinkEnabled: data.config.magic_link_enabled,
passkeyEnabled: data.config.passkey_enabled,
clientTeamCreationEnabled: data.config.client_team_creation_enabled,
clientUserDeletionEnabled: data.config.client_user_deletion_enabled,
allowLocalhost: data.config.allow_localhost,
oauthAccountMergeStrategy: data.config.oauth_account_merge_strategy,
allowUserApiKeys: data.config.allow_user_api_keys,
allowTeamApiKeys: data.config.allow_team_api_keys,
oauthProviders: data.config.oauth_providers.map((p) => ((p.type === 'shared' ? {
id: p.id,
type: 'shared',
} as const : {
id: p.id,
type: 'standard',
clientId: p.client_id ?? throwErr("Client ID is missing"),
clientSecret: p.client_secret ?? throwErr("Client secret is missing"),
facebookConfigId: p.facebook_config_id,
microsoftTenantId: p.microsoft_tenant_id,
appleBundleIds: p.apple_bundle_ids,
} as const))),
emailConfig: data.config.email_config.type === 'shared' ? {
type: 'shared'
} : {
type: 'standard',
host: data.config.email_config.host ?? throwErr("Email host is missing"),
port: data.config.email_config.port ?? throwErr("Email port is missing"),
username: data.config.email_config.username ?? throwErr("Email username is missing"),
password: data.config.email_config.password ?? throwErr("Email password is missing"),
senderName: data.config.email_config.sender_name ?? throwErr("Email sender name is missing"),
senderEmail: data.config.email_config.sender_email ?? throwErr("Email sender email is missing"),
},
emailTheme: data.config.email_theme,
domains: data.config.domains.map((d) => ({
domain: d.domain,
handlerPath: d.handler_path,
})),
createTeamOnSignUp: data.config.create_team_on_sign_up,
teamCreatorDefaultPermissions: data.config.team_creator_default_permissions,
teamMemberDefaultPermissions: data.config.team_member_default_permissions,
userDefaultPermissions: data.config.user_default_permissions,
},
async getConfig() {
return app._adminConfigFromCrud(await app._interface.getConfig());
},
// IF_PLATFORM react-like
useConfig() {
const config = useAsyncCache(app._configOverridesCache, [], "project.useConfig()");
return useMemo(() => app._adminConfigFromCrud(config), [config]);
},
// END_PLATFORM
async updateConfig(configOverride: EnvironmentConfigOverrideOverride) {
await app._interface.updateConfigOverride("environment", configOverride);
await app._refreshProjectConfig();
},
async pushConfig(config: EnvironmentConfigOverrideOverride, options: PushConfigOptions) {
await app._interface.setConfigOverride("branch", config, pushedConfigSourceToApi(options.source));
await app._refreshProjectConfig();
},
async updatePushedConfig(config: EnvironmentConfigOverrideOverride) {
await app._interface.updateConfigOverride("branch", config);
await app._refreshProjectConfig();
},
async getPushedConfigSource(): Promise<PushedConfigSource> {
const apiSource = await app._interface.getPushedConfigSource();
return apiToPushedConfigSource(apiSource);
},
async unlinkPushedConfigSource(): Promise<void> {
await app._interface.unlinkPushedConfigSource();
await app._refreshProjectConfig();
},
async resetConfigOverrideKeys(level: "branch" | "environment", keys: string[]): Promise<void> {
await app._interface.resetConfigOverrideKeys(level, keys);
await app._refreshProjectConfig();
},
async getConfigOverride(level: "branch" | "environment"): Promise<Record<string, unknown>> {
const result = await app._interface.getConfigOverride(level);
return JSON.parse(result.config_string);
},
async replaceConfigOverride(level: "branch" | "environment", config: Record<string, unknown>): Promise<void> {
if (level === "branch") {
const source = await app._interface.getPushedConfigSource();
await app._interface.setConfigOverride(level, config, source);
} else {
await app._interface.setConfigOverride(level, config);
}
await app._refreshProjectConfig();
},
async update(update: AdminProjectUpdateOptions) {
const { requirePublishableClientKey, ...projectUpdate } = update;
const updateOptions = adminProjectUpdateOptionsToCrud(projectUpdate);
const hasConfigUpdate = !!updateOptions.config
&& typedValues(updateOptions.config).some((value) => value !== undefined);
const hasProjectUpdate = typedEntries(updateOptions).some(([key, value]) => {
if (key === "config") return hasConfigUpdate;
return value !== undefined;
});
if (hasProjectUpdate) {
await app._interface.updateProject(updateOptions);
await onRefresh();
}
if (requirePublishableClientKey !== undefined) {
await app._interface.updateConfigOverride("project", {
"project.requirePublishableClientKey": requirePublishableClientKey,
});
await app._refreshProjectConfig();
}
},
async delete() {
await app._interface.deleteProject();
},
async getProductionModeErrors() {
return getProductionModeErrors(data);
},
// IF_PLATFORM react-like
useProductionModeErrors() {
return getProductionModeErrors(data);
},
// END_PLATFORM
};
}
_adminEmailTemplateFromCrud(data: EmailTemplateCrud['Admin']['Read']): AdminEmailTemplate {
return {
type: data.type,
subject: data.subject,
content: data.content,
isDefault: data.is_default,
};
}
override async getProject(): Promise<AdminProject> {
return this._adminProjectFromCrud(
Result.orThrow(await this._adminProjectCache.getOrWait([], "write-only")),
() => this._refreshProject()
);
}
// IF_PLATFORM react-like
override useProject(): AdminProject {
const crud = useAsyncCache(this._adminProjectCache, [], "adminApp.useProject()");
return useMemo(() => this._adminProjectFromCrud(
crud,
() => this._refreshProject()
), [crud]);
}
// END_PLATFORM
protected _createInternalApiKeyBaseFromCrud(data: InternalApiKeyBaseCrudRead): InternalApiKeyBase {
const app = this;
return {
id: data.id,
description: data.description,
expiresAt: new Date(data.expires_at_millis),
manuallyRevokedAt: data.manually_revoked_at_millis ? new Date(data.manually_revoked_at_millis) : null,
createdAt: new Date(data.created_at_millis),
isValid() {
return this.whyInvalid() === null;
},
whyInvalid() {
if (this.expiresAt.getTime() < Date.now()) return "expired";
if (this.manuallyRevokedAt) return "manually-revoked";
return null;
},
async revoke() {
const res = await app._interface.revokeInternalApiKeyById(data.id);
await app._refreshInternalApiKeys();
return res;
}
};
}
protected _createInternalApiKeyFromCrud(data: InternalApiKeysCrud["Admin"]["Read"]): InternalApiKey {
return {
...this._createInternalApiKeyBaseFromCrud(data),
publishableClientKey: data.publishable_client_key ? { lastFour: data.publishable_client_key.last_four } : null,
secretServerKey: data.secret_server_key ? { lastFour: data.secret_server_key.last_four } : null,
superSecretAdminKey: data.super_secret_admin_key ? { lastFour: data.super_secret_admin_key.last_four } : null,
};
}
protected _createInternalApiKeyFirstViewFromCrud(data: InternalApiKeyCreateCrudResponse): InternalApiKeyFirstView {
return {
...this._createInternalApiKeyBaseFromCrud(data),
publishableClientKey: data.publishable_client_key,
secretServerKey: data.secret_server_key,
superSecretAdminKey: data.super_secret_admin_key,
};
}
async listInternalApiKeys(): Promise<InternalApiKey[]> {
const crud = Result.orThrow(await this._internalApiKeysCache.getOrWait([], "write-only"));
return crud.map((j) => this._createInternalApiKeyFromCrud(j));
}
// IF_PLATFORM react-like
useInternalApiKeys(): InternalApiKey[] {
const crud = useAsyncCache(this._internalApiKeysCache, [], "adminApp.useInternalApiKeys()");
return useMemo(() => {
return crud.map((j) => this._createInternalApiKeyFromCrud(j));
}, [crud]);
}
// END_PLATFORM
async createInternalApiKey(options: InternalApiKeyCreateOptions): Promise<InternalApiKeyFirstView> {
const crud = await this._interface.createInternalApiKey(internalApiKeyCreateOptionsToCrud(options));
await this._refreshInternalApiKeys();
return this._createInternalApiKeyFirstViewFromCrud(crud);
}
// IF_PLATFORM react-like
useEmailThemes(): { id: string, displayName: string }[] {
const crud = useAsyncCache(this._adminEmailThemesCache, [], "adminApp.useEmailThemes()");
return useMemo(() => {
return crud.map((theme) => ({
id: theme.id,
displayName: theme.display_name,
}));
}, [crud]);
}
useEmailTemplates(): { id: string, displayName: string, themeId?: string, tsxSource: string }[] {
const crud = useAsyncCache(this._adminEmailTemplatesCache, [], "adminApp.useEmailTemplates()");
return useMemo(() => {
return crud.map((template) => ({
id: template.id,
displayName: template.display_name,
themeId: template.theme_id,
tsxSource: template.tsx_source,
}));
}, [crud]);
}
useEmailDrafts(): { id: string, displayName: string, themeId: string | undefined | false, tsxSource: string, sentAt: Date | null }[] {
const crud = useAsyncCache(this._adminEmailDraftsCache, [], "adminApp.useEmailDrafts()");
return useMemo(() => {
return crud.map((draft) => ({
id: draft.id,
displayName: draft.display_name,
themeId: draft.theme_id,
tsxSource: draft.tsx_source,
sentAt: draft.sent_at_millis ? new Date(draft.sent_at_millis) : null,
}));
}, [crud]);
}
// END_PLATFORM
async listEmailThemes(): Promise<{ id: string, displayName: string }[]> {
const crud = Result.orThrow(await this._adminEmailThemesCache.getOrWait([], "write-only"));
return crud.map((theme) => ({
id: theme.id,
displayName: theme.display_name,
}));
}
async listEmailTemplates(): Promise<{ id: string, displayName: string, themeId?: string, tsxSource: string }[]> {
const crud = Result.orThrow(await this._adminEmailTemplatesCache.getOrWait([], "write-only"));
return crud.map((template) => ({
id: template.id,
displayName: template.display_name,
themeId: template.theme_id,
tsxSource: template.tsx_source,
}));
}
async listEmailDrafts(): Promise<{ id: string, displayName: string, themeId: string | undefined | false, tsxSource: string, sentAt: Date | null }[]> {
const crud = Result.orThrow(await this._adminEmailDraftsCache.getOrWait([], "write-only"));
return crud.map((draft) => ({
id: draft.id,
displayName: draft.display_name,
themeId: draft.theme_id,
tsxSource: draft.tsx_source,
sentAt: draft.sent_at_millis ? new Date(draft.sent_at_millis) : null,
}));
}
async createTeamPermissionDefinition(data: AdminTeamPermissionDefinitionCreateOptions): Promise<AdminTeamPermission> {
const crud = await this._interface.createTeamPermissionDefinition(adminTeamPermissionDefinitionCreateOptionsToCrud(data));
await this._adminTeamPermissionDefinitionsCache.refresh([]);
return this._serverTeamPermissionDefinitionFromCrud(crud);
}
async updateTeamPermissionDefinition(permissionId: string, data: AdminTeamPermissionDefinitionUpdateOptions) {
await this._interface.updateTeamPermissionDefinition(permissionId, adminTeamPermissionDefinitionUpdateOptionsToCrud(data));
await this._adminTeamPermissionDefinitionsCache.refresh([]);
}
async deleteTeamPermissionDefinition(permissionId: string): Promise<void> {
await this._interface.deleteTeamPermissionDefinition(permissionId);
await this._adminTeamPermissionDefinitionsCache.refresh([]);
}
async listTeamPermissionDefinitions(): Promise<AdminTeamPermissionDefinition[]> {
const crud = Result.orThrow(await this._adminTeamPermissionDefinitionsCache.getOrWait([], "write-only"));
return crud.map((p) => this._serverTeamPermissionDefinitionFromCrud(p));
}
async listTeamPermissionDefinitionsPaginated(
options: { limit: number, cursor?: string, query?: string },
): Promise<{ items: AdminTeamPermissionDefinition[], nextCursor: string | null }> {
const result = await this._interface.listTeamPermissionDefinitionsPaginated(options);
return {
items: result.items.map((p) => this._serverTeamPermissionDefinitionFromCrud(p)),
nextCursor: result.nextCursor,
};
}
// IF_PLATFORM react-like
useTeamPermissionDefinitions(): AdminTeamPermissionDefinition[] {
const crud = useAsyncCache(this._adminTeamPermissionDefinitionsCache, [], "adminApp.useTeamPermissionDefinitions()");
return useMemo(() => {
return crud.map((p) => this._serverTeamPermissionDefinitionFromCrud(p));
}, [crud]);
}
// END_PLATFORM
async createProjectPermissionDefinition(data: AdminProjectPermissionDefinitionCreateOptions): Promise<AdminProjectPermission> {
const crud = await this._interface.createProjectPermissionDefinition(adminProjectPermissionDefinitionCreateOptionsToCrud(data));
await this._adminProjectPermissionDefinitionsCache.refresh([]);
return this._serverProjectPermissionDefinitionFromCrud(crud);
}
async updateProjectPermissionDefinition(permissionId: string, data: AdminProjectPermissionDefinitionUpdateOptions) {
await this._interface.updateProjectPermissionDefinition(permissionId, adminProjectPermissionDefinitionUpdateOptionsToCrud(data));
await this._adminProjectPermissionDefinitionsCache.refresh([]);
}
async deleteProjectPermissionDefinition(permissionId: string): Promise<void> {
await this._interface.deleteProjectPermissionDefinition(permissionId);
await this._adminProjectPermissionDefinitionsCache.refresh([]);
}
async listProjectPermissionDefinitions(): Promise<AdminProjectPermissionDefinition[]> {
const crud = Result.orThrow(await this._adminProjectPermissionDefinitionsCache.getOrWait([], "write-only"));
return crud.map((p) => this._serverProjectPermissionDefinitionFromCrud(p));
}
// IF_PLATFORM react-like
useProjectPermissionDefinitions(): AdminProjectPermissionDefinition[] {
const crud = useAsyncCache(this._adminProjectPermissionDefinitionsCache, [], "adminApp.useProjectPermissionDefinitions()");
return useMemo(() => {
return crud.map((p) => this._serverProjectPermissionDefinitionFromCrud(p));
}, [crud]);
}
// END_PLATFORM
// IF_PLATFORM react-like
useSvixToken(): { token: string, url: string | undefined } {
const crud = useAsyncCache(this._svixTokenCache, [], "adminApp.useSvixToken()");
return { token: crud.token, url: crud.url };
}
// END_PLATFORM
protected override async _refreshProject() {
await Promise.all([
super._refreshProject(),
this._adminProjectCache.refresh([]),
]);
}
protected async _refreshProjectConfig() {
await Promise.all([
this._configOverridesCache.refresh([]),
this._adminProjectCache.refresh([]),
]);
}
protected async _refreshInternalApiKeys() {
await this._internalApiKeysCache.refresh([]);
}
protected override async _refreshUsers() {
await Promise.all([
super._refreshUsers(),
this._metricsCache.refreshWhere(() => true),
this._metricsUserCountsCache.refresh([]),
]);
}
get [hexclaveAppInternalsSymbol]() {
return {
...super[hexclaveAppInternalsSymbol],
// IF_PLATFORM react-like
useMetrics: (
includeAnonymous: boolean = false,
filters?: { country_code?: string, referrer?: string, browser?: string, os?: string, device?: string, since?: string, until?: string },
): MetricsResponse => {
const filtersKey = (() => {
if (filters == null) return "";
const params = new URLSearchParams();
for (const key of ["browser", "country_code", "device", "os", "referrer", "since", "until"] as const) {
const v = filters[key];
if (v != null) params.set(key, v);
}
return params.toString();
})();
return useAsyncCache(this._metricsCache, [includeAnonymous, filtersKey] as const, "adminApp.useMetrics()") as MetricsResponse;
},
useUserActivity: (userId: string): UserActivityResponse => {
return useAsyncCache(this._userActivityCache, [userId] as const, "adminApp.useUserActivity()") as UserActivityResponse;
},
useMetricsUserCounts: (): MetricsUserCounts => {
return useAsyncCache(this._metricsUserCountsCache, [] as const, "adminApp.useMetricsUserCounts()") as MetricsUserCounts;
},
// END_PLATFORM
};
}
async sendTestEmail(options: {
recipientEmail: string,
emailConfig: EmailConfig,
}): Promise<Result<undefined, { errorMessage: string }>> {
let response: { success: boolean, error_message?: string };
try {
response = await this._interface.sendTestEmail({
recipient_email: options.recipientEmail,
email_config: {
...(pick(options.emailConfig, ['host', 'port', 'username', 'password'])),
sender_email: options.emailConfig.senderEmail,
sender_name: options.emailConfig.senderName,
},
});
} catch (error) {
// Translate the quota-exhaustion KnownError into the existing
// Result.error shape so SDK/dashboard callers don't need to branch on
// exceptions. The backend throws `ItemQuantityInsufficientAmount`
// (consistent with every other limit-rejection endpoint), but this
// method's historical contract has always been a `Result`.
if (error instanceof KnownErrors.ItemQuantityInsufficientAmount) {
return Result.error({
errorMessage: "Monthly email sending limit exceeded for your plan. Please upgrade your plan or wait until next month before sending more test emails.",
});
}
throw error;
}
if (response.success) {
return Result.ok(undefined);
} else {
return Result.error({ errorMessage: response.error_message ?? throwErr("Email test error not specified") });
}
}
async sendTestWebhook(options: { endpointId: string }): Promise<Result<undefined, { errorMessage: string }>> {
const response = await this._interface.sendTestWebhook({
endpoint_id: options.endpointId,
});
if (response.success) {
return Result.ok(undefined);
} else {
return Result.error({ errorMessage: response.error_message ?? throwErr("Webhook test error not specified") });
}
}
async listSentEmails(): Promise<AdminSentEmail[]> {
const response = await this._interface.listSentEmails();
return response.items.map((email) => ({
id: email.id,
to: email.to ?? [],
subject: email.subject,
recipient: email.to?.[0] ?? "",
sentAt: new Date(email.sent_at_millis),
error: email.error,
}));
}
async setupManagedEmailProvider(options: { subdomain: string, senderLocalPart: string }): Promise<ManagedEmailProviderSetupResult> {
const response = await this._interface.setupManagedEmailProvider({
subdomain: options.subdomain,
sender_local_part: options.senderLocalPart,
});
return {
domainId: response.domain_id,
subdomain: response.subdomain,
senderLocalPart: response.sender_local_part,
nameServerRecords: response.name_server_records,
status: response.status,
};
}
async checkManagedEmailStatus(options: { domainId: string, subdomain: string, senderLocalPart: string }): Promise<ManagedEmailProviderStatus> {
const response = await this._interface.checkManagedEmailStatus({
domain_id: options.domainId,
subdomain: options.subdomain,
sender_local_part: options.senderLocalPart,
});
return {
status: response.status,
};
}
async listManagedEmailDomains(): Promise<ManagedEmailProviderListItem[]> {
const response = await this._interface.listManagedEmailDomains();
return response.items.map((item) => ({
domainId: item.domain_id,
subdomain: item.subdomain,
senderLocalPart: item.sender_local_part,
status: item.status,
nameServerRecords: item.name_server_records,
}));
}
async applyManagedEmailProvider(options: { domainId: string }): Promise<{ status: "applied" }> {
const result = await this._interface.applyManagedEmailProvider({
domain_id: options.domainId,
});
await this._refreshProjectConfig();
return result;
}
async deleteManagedEmailDomain(options: { resendDomainId: string }): Promise<{ status: "deleted" }> {
return await this._interface.deleteManagedEmailDomain({
resend_domain_id: options.resendDomainId,
});
}
async sendSignInInvitationEmail(email: string, callbackUrl: string): Promise<void> {
await this._interface.sendSignInInvitationEmail(email, callbackUrl);
}
async createEmailTemplate(displayName: string): Promise<{ id: string }> {
const result = await this._interface.createEmailTemplate(displayName);
await this._adminEmailTemplatesCache.refresh([]);
return result;
}
async deleteEmailTemplate(id: string): Promise<void> {
await this._interface.deleteEmailTemplate(id);
await this._adminEmailTemplatesCache.refresh([]);
}
async createEmailDraft(options: { displayName: string, themeId?: string | false, tsxSource?: string }): Promise<{ id: string }> {
const result = await this._interface.createEmailDraft({
display_name: options.displayName,
theme_id: options.themeId,
tsx_source: options.tsxSource,
});
await this._adminEmailDraftsCache.refresh([]);
return result;
}
async updateEmailDraft(id: string, data: { displayName?: string, themeId?: string | undefined | false, tsxSource?: string }): Promise<void> {
await this._interface.updateEmailDraft(id, {
display_name: data.displayName,
theme_id: data.themeId,
tsx_source: data.tsxSource,
});
await this._adminEmailDraftsCache.refresh([]);
}
async deleteEmailDraft(id: string): Promise<void> {
await this._interface.deleteEmailDraft(id);
const current = this._adminEmailDraftsCache.getIfCached([]);
if (current.status === "ok" && current.data.status === "ok") {
this._adminEmailDraftsCache.forceSetCachedValue([], Result.ok(current.data.data.filter((d) => d.id !== id)));
}
await this._adminEmailDraftsCache.refresh([]);
}
async refreshEmailDrafts(): Promise<void> {
await this._adminEmailDraftsCache.refresh([]);
}
async saveChatMessage(threadId: string, message: any): Promise<void> {
await this._interface.saveChatMessage(threadId, message);
}
async listChatMessages(threadId: string): Promise<{ messages: Array<any> }> {
return await this._interface.listChatMessages(threadId);
}
async rewriteTemplateSourceWithAI(templateTsxSource: string): Promise<{ tsxSource: string }> {
const result = await this._interface.rewriteTemplateSourceWithAI(templateTsxSource);
return { tsxSource: result.tsx_source };
}
async createEmailTheme(displayName: string): Promise<{ id: string }> {
const result = await this._interface.createEmailTheme(displayName);
await this._adminEmailThemesCache.refresh([]);
return result;
}
async getEmailPreview(options: { themeId?: string | null | false, themeTsxSource?: string, templateId?: string, templateTsxSource?: string }): Promise<string> {
return (await this._interface.renderEmailPreview(options)).html;
}
// IF_PLATFORM react-like
useEmailPreview(options: { themeId?: string | null | false, themeTsxSource?: string, templateId?: string, templateTsxSource?: string }): string {
const crud = useAsyncCache(this._emailPreviewCache, [options.themeId, options.themeTsxSource, options.templateId, options.templateTsxSource] as const, "adminApp.useEmailPreview()");
return crud.html;
}
// END_PLATFORM
async getEmailPreviewWithEditableMarkers(options: { themeId?: string | null | false, themeTsxSource?: string, templateId?: string, templateTsxSource?: string, editableSource?: 'template' | 'theme' | 'both' }): Promise<{ html: string, editableRegions?: Record<string, unknown> }> {
const result = await this._interface.renderEmailPreview({ ...options, editableMarkers: true, editableSource: options.editableSource });
return { html: result.html, editableRegions: result.editable_regions };
}
// IF_PLATFORM react-like
useEmailPreviewWithEditableMarkers(options: { themeId?: string | null | false, themeTsxSource?: string, templateId?: string, templateTsxSource?: string, editableSource?: 'template' | 'theme' | 'both' }): { html: string, editableRegions?: Record<string, unknown> } {
const crud = useAsyncCache(this._emailPreviewWithEditableMarkersCache, [options.themeId, options.themeTsxSource, options.templateId, options.templateTsxSource, options.editableSource] as const, "adminApp.useEmailPreviewWithEditableMarkers()");
return { html: crud.html, editableRegions: crud.editable_regions };
}
// END_PLATFORM
// IF_PLATFORM react-like
useEmailTheme(id: string): { displayName: string, tsxSource: string } {
const crud = useAsyncCache(this._adminEmailThemeCache, [id] as const, "adminApp.useEmailTheme()");
return {
displayName: crud.display_name,
tsxSource: crud.tsx_source,
};
}
// END_PLATFORM
async updateEmailTheme(id: string, tsxSource: string): Promise<void> {
await this._interface.updateEmailTheme(id, tsxSource);
await this._adminEmailThemesCache.refresh([]);
await this._adminEmailThemeCache.invalidate([id]);
}
async deleteEmailTheme(id: string): Promise<void> {
await this._interface.deleteEmailTheme(id);
await this._adminEmailThemesCache.refresh([]);
await this._adminEmailThemeCache.invalidate([id]);
}
async updateEmailTemplate(id: string, tsxSource: string, themeId: string | null | false): Promise<{ renderedHtml: string }> {
const result = await this._interface.updateEmailTemplate(id, tsxSource, themeId);
await this._adminEmailTemplatesCache.refresh([]);
return { renderedHtml: result.rendered_html };
}
async setupPayments(): Promise<{ url: string }> {
const result = await this._interface.setupPayments();
await this._stripeAccountInfoCache.refresh([]);
return result;
}
async createStripeWidgetAccountSession(): Promise<{ client_secret: string }> {
return await this._interface.createStripeWidgetAccountSession();
}
async getPaymentMethodConfigs(): Promise<{ configId: string, methods: Array<{ id: string, name: string, enabled: boolean, available: boolean, overridable: boolean }> } | null> {
return await this._interface.getPaymentMethodConfigs();
}
async updatePaymentMethodConfigs(configId: string, updates: Record<string, 'on' | 'off'>): Promise<void> {
await this._interface.updatePaymentMethodConfigs(configId, updates);
}
async createItemQuantityChange(options: (
{ userId: string, itemId: string, quantity: number, expiresAt?: string, description?: string } |
{ teamId: string, itemId: string, quantity: number, expiresAt?: string, description?: string } |
{ customCustomerId: string, itemId: string, quantity: number, expiresAt?: string, description?: string }
)): Promise<void> {
await this._interface.updateItemQuantity(
{ itemId: options.itemId, ...("userId" in options ? { userId: options.userId } : ("teamId" in options ? { teamId: options.teamId } : { customCustomerId: options.customCustomerId })) },
{
delta: options.quantity,
expires_at: options.expiresAt,
description: options.description,
allow_negative: true,
}
);
}
async refundTransaction(options: {
type: "subscription" | "one-time-purchase",
id: string,
invoiceId?: string,
amountUsd: MoneyAmount,
endAction?: "now" | "at-period-end",
}): Promise<{ refundTransactionId: string }> {
const result = await this._interface.refundTransaction({
type: options.type,
id: options.id,
invoiceId: options.invoiceId,
amountUsd: options.amountUsd,
endAction: options.endAction,
});
await this._transactionsCache.invalidateWhere(() => true);
return { refundTransactionId: result.refundTransactionId };
}
async listTransactions(params: { cursor?: string, limit?: number, type?: TransactionType, customerType?: 'user' | 'team' | 'custom', customerId?: string }): Promise<{ transactions: Transaction[], nextCursor: string | null }> {
const crud = Result.orThrow(await this._transactionsCache.getOrWait([params.cursor, params.limit, params.type, params.customerType, params.customerId] as const, "write-only"));
return crud;
}
// Email Outbox methods
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Complex discriminated union conversion from API response
private _emailOutboxCrudToAdmin(crud: any): AdminEmailOutbox {
const recipient = crud.to;
let to: AdminEmailOutbox["to"];
if (recipient.type === "user-primary-email") {
to = { type: "user-primary-email", userId: recipient.user_id };
} else if (recipient.type === "user-custom-emails") {
to = { type: "user-custom-emails", userId: recipient.user_id, emails: recipient.emails };
} else {
to = { type: "custom-emails", emails: recipient.emails };
}
// Base fields present on all emails
const base = {
id: crud.id as string,
createdAt: new Date(crud.created_at_millis),
updatedAt: new Date(crud.updated_at_millis),
tsxSource: crud.tsx_source as string,
themeId: (crud.theme_id as string | null) ?? null,
to,
scheduledAt: new Date(crud.scheduled_at_millis),
// Source tracking for grouping emails by template/draft
createdWith: crud.created_with as "draft" | "programmatic-call",
emailDraftId: crud.email_draft_id as string | null,
emailProgrammaticCallTemplateId: crud.email_programmatic_call_template_id as string | null,
variables: (crud.variables ?? {}) as Record<string, Json>,
isPaused: false as const,
hasRendered: false as const,
hasDelivered: false as const,
// Retry tracking fields
sendRetries: crud.send_retries as number,
nextSendRetryAt: crud.next_send_retry_at_millis ? new Date(crud.next_send_retry_at_millis) : null,
sendAttemptErrors: crud.send_attempt_errors ? (crud.send_attempt_errors as Array<{
attempt_number: number,
timestamp: string,
external_message: string,
external_details: Record<string, unknown>,
internal_message: string,
internal_details: Record<string, unknown>,
}>).map((e) => ({
attemptNumber: e.attempt_number,
timestamp: e.timestamp,
externalMessage: e.external_message,
externalDetails: e.external_details,
internalMessage: e.internal_message,
internalDetails: e.internal_details,
})) : null,
};
// Rendered fields (available after rendering completes successfully)
const rendered = crud.has_rendered ? {
...base,
startedRenderingAt: new Date(crud.started_rendering_at_millis),
renderedAt: new Date(crud.rendered_at_millis),
subject: crud.subject as string,
html: crud.html as string | null,
text: crud.text as string | null,
isTransactional: crud.is_transactional as boolean,
isHighPriority: crud.is_high_priority as boolean,
notificationCategoryId: crud.notification_category_id as string | null,
hasRendered: true as const,
} : null;
// Started sending fields
const startedSending = rendered && crud.started_sending_at_millis ? {
...rendered,
startedSendingAt: new Date(crud.started_sending_at_millis),
} : null;
// Finished delivering fields
const finishedDelivering = startedSending && crud.has_delivered ? {
...startedSending,
deliveredAt: new Date(crud.delivered_at_millis),
hasDelivered: true as const,
} : null;
// Use type assertion at the end because TypeScript has trouble with
// spread + override patterns on discriminated unions with const literal types
const result = (() => {
switch (crud.status) {
case "paused": {
return {
...base,
status: "paused" as const,
simpleStatus: "in-progress" as const,
isPaused: true as const,
};
}
case "preparing": {
return {
...base,
status: "preparing" as const,
simpleStatus: "in-progress" as const,
};
}
case "rendering": {
return {
...base,
status: "rendering" as const,
simpleStatus: "in-progress" as const,
startedRenderingAt: new Date(crud.started_rendering_at_millis),
};
}
case "render-error": {
return {
...base,
status: "render-error" as const,
simpleStatus: "error" as const,
startedRenderingAt: new Date(crud.started_rendering_at_millis),
renderedAt: new Date(crud.rendered_at_millis),
renderError: crud.render_error,
};
}
case "scheduled": {
return {
...rendered!,
status: "scheduled" as const,
simpleStatus: "in-progress" as const,
};
}
case "queued": {
return {
...rendered!,
status: "queued" as const,
simpleStatus: "in-progress" as const,
};
}
case "sending": {
return {
...startedSending!,
status: "sending" as const,
simpleStatus: "in-progress" as const,
};
}
case "server-error": {
return {
...startedSending!,
status: "server-error" as const,
simpleStatus: "error" as const,
errorAt: new Date(crud.error_at_millis),
serverError: crud.server_error,
};
}
case "skipped": {
return {
...base,
status: "skipped" as const,
simpleStatus: "ok" as const,
skippedAt: new Date(crud.skipped_at_millis),
skippedReason: crud.skipped_reason,
skippedDetails: crud.skipped_details ?? {},
hasRendered: crud.has_rendered as boolean,
// Optional fields
startedRenderingAt: crud.started_rendering_at_millis ? new Date(crud.started_rendering_at_millis) : undefined,
renderedAt: crud.rendered_at_millis ? new Date(crud.rendered_at_millis) : undefined,
subject: crud.subject,
html: crud.html,
text: crud.text,
isTransactional: crud.is_transactional,
isHighPriority: crud.is_high_priority,
notificationCategoryId: crud.notification_category_id,
startedSendingAt: crud.started_sending_at_millis ? new Date(crud.started_sending_at_millis) : undefined,
};
}
case "bounced": {
return {
...startedSending!,
status: "bounced" as const,
simpleStatus: "error" as const,
bouncedAt: new Date(crud.bounced_at_millis),
};
}
case "delivery-delayed": {
return {
...startedSending!,
status: "delivery-delayed" as const,
simpleStatus: "ok" as const,
deliveryDelayedAt: new Date(crud.delivery_delayed_at_millis),
};
}
case "sent": {
return {
...finishedDelivering!,
status: "sent" as const,
simpleStatus: "ok" as const,
canHaveDeliveryInfo: crud.can_have_delivery_info,
};
}
case "opened": {
return {
...finishedDelivering!,
status: "opened" as const,
simpleStatus: "ok" as const,
openedAt: new Date(crud.opened_at_millis),
canHaveDeliveryInfo: true as const,
};
}
case "clicked": {
return {
...finishedDelivering!,
status: "clicked" as const,
simpleStatus: "ok" as const,
clickedAt: new Date(crud.clicked_at_millis),
canHaveDeliveryInfo: true as const,
};
}
case "marked-as-spam": {
return {
...finishedDelivering!,
status: "marked-as-spam" as const,
simpleStatus: "ok" as const,
markedAsSpamAt: new Date(crud.marked_as_spam_at_millis),
canHaveDeliveryInfo: true as const,
};
}
default: {
throw new HexclaveAssertionError(`Unknown email outbox status: ${crud.status}`, { status: crud.status });
}
}
})();
// The type system has difficulty with spread + override patterns on discriminated unions,
// so we use a type assertion here. The switch statement above ensures we return the correct shape.
return result as AdminEmailOutbox;
}
async listOutboxEmails(options?: { status?: string, simpleStatus?: string, userId?: string, limit?: number, cursor?: string }): Promise<{ items: AdminEmailOutbox[], nextCursor: string | null }> {
const response = await this._interface.listOutboxEmails({
status: options?.status,
simple_status: options?.simpleStatus,
user_id: options?.userId,
limit: options?.limit,
cursor: options?.cursor,
});
return {
items: response.items.map((item) => this._emailOutboxCrudToAdmin(item)),
nextCursor: response.pagination?.next_cursor ?? null,
};
}
async getOutboxEmail(id: string): Promise<AdminEmailOutbox> {
const response = await this._interface.getOutboxEmail(id);
return this._emailOutboxCrudToAdmin(response);
}
async updateOutboxEmail(id: string, options: EmailOutboxUpdateOptions): Promise<AdminEmailOutbox> {
const response = await this._interface.updateOutboxEmail(id, {
is_paused: options.isPaused,
scheduled_at_millis: options.scheduledAtMillis,
cancel: options.cancel,
tsx_source: options.tsxSource,
theme_id: options.themeId,
});
return this._emailOutboxCrudToAdmin(response);
}
async pauseOutboxEmail(id: string): Promise<AdminEmailOutbox> {
return await this.updateOutboxEmail(id, { isPaused: true });
}
async unpauseOutboxEmail(id: string): Promise<AdminEmailOutbox> {
return await this.updateOutboxEmail(id, { isPaused: false });
}
async cancelOutboxEmail(id: string): Promise<AdminEmailOutbox> {
return await this.updateOutboxEmail(id, { cancel: true });
}
// IF_PLATFORM react-like
useTransactions(params: { cursor?: string, limit?: number, type?: TransactionType, customerType?: 'user' | 'team' | 'custom', customerId?: string }): { transactions: Transaction[], nextCursor: string | null } {
const data = useAsyncCache(this._transactionsCache, [params.cursor, params.limit, params.type, params.customerType, params.customerId] as const, "adminApp.useTransactions()");
return data;
}
// END_PLATFORM
async getStripeAccountInfo(): Promise<null | { account_id: string, charges_enabled: boolean, details_submitted: boolean, payouts_enabled: boolean }> {
return await this._interface.getStripeAccountInfo();
}
// IF_PLATFORM react-like
useStripeAccountInfo(): { account_id: string, charges_enabled: boolean, details_submitted: boolean, payouts_enabled: boolean } | null {
const data = useAsyncCache(this._stripeAccountInfoCache, [], "adminApp.useStripeAccountInfo()");
return data;
}
// END_PLATFORM
async queryAnalytics(options: AnalyticsQueryOptions): Promise<AnalyticsQueryResponse> {
return await this._interface.queryAnalytics(options);
}
async getAnalyticsClickmap(options: AnalyticsClickmapOptions): Promise<AnalyticsClickmapResponse> {
return await this._interface.getAnalyticsClickmap({
kind: options.kind,
member_user_ids: options.memberUserIds,
route_path: options.routePath,
route_regex: options.routeRegex,
url_pattern: options.urlPattern,
user_id: options.userId,
replay_id: options.replayId,
device: options.device,
viewport_width_min: options.viewportWidthMin,
viewport_width_max: options.viewportWidthMax,
sampling: options.sampling,
since: options.since,
until: options.until,
});
}
async createAnalyticsClickmapToken(options: { origin: string }): Promise<AnalyticsClickmapTokenResponse> {
return await this._interface.createAnalyticsClickmapToken(options);
}
async listSessionReplays(options?: ListSessionReplaysOptions): Promise<ListSessionReplaysResult> {
const response = await this._interface.listSessionReplays({
cursor: options?.cursor,
limit: options?.limit,
user_ids: options?.userIds,
team_ids: options?.teamIds,
duration_ms_min: options?.durationMsMin,
duration_ms_max: options?.durationMsMax,
last_event_at_from_millis: options?.lastEventAtFromMillis,
last_event_at_to_millis: options?.lastEventAtToMillis,
click_count_min: options?.clickCountMin,
});
const items: AdminSessionReplay[] = response.items.map((r) => ({
id: r.id,
projectUser: {
id: r.project_user.id,
displayName: r.project_user.display_name,
primaryEmail: r.project_user.primary_email,
},
startedAt: new Date(r.started_at_millis),
lastEventAt: new Date(r.last_event_at_millis),
chunkCount: r.chunk_count,
eventCount: r.event_count,
}));
return {
items,
nextCursor: response.pagination.next_cursor,
};
}
async getSessionReplay(sessionReplayId: string): Promise<AdminSessionReplay> {
const response = await this._interface.getSessionReplay(sessionReplayId);
return {
id: response.id,
projectUser: {
id: response.project_user.id,
displayName: response.project_user.display_name,
primaryEmail: response.project_user.primary_email,
},
startedAt: new Date(response.started_at_millis),
lastEventAt: new Date(response.last_event_at_millis),
chunkCount: response.chunk_count,
eventCount: response.event_count,
};
}
async listSessionReplayChunks(sessionReplayId: string, options?: ListSessionReplayChunksOptions): Promise<ListSessionReplayChunksResult> {
const response = await this._interface.listSessionReplayChunks(sessionReplayId, {
cursor: options?.cursor,
limit: options?.limit,
});
const items: AdminSessionReplayChunk[] = response.items.map((c) => ({
id: c.id,
batchId: c.batch_id,
sessionReplaySegmentId: c.session_replay_segment_id,
browserSessionId: c.browser_session_id,
eventCount: c.event_count,
byteLength: c.byte_length,
firstEventAt: new Date(c.first_event_at_millis),
lastEventAt: new Date(c.last_event_at_millis),
createdAt: new Date(c.created_at_millis),
}));
return {
items,
nextCursor: response.pagination.next_cursor,
};
}
async getSessionReplayChunkEvents(sessionReplayId: string, chunkId: string): Promise<AdminGetSessionReplayChunkEventsResponse> {
return await this._interface.getSessionReplayChunkEvents(sessionReplayId, chunkId);
}
async getSessionReplayEvents(sessionReplayId: string, options?: { offset?: number, limit?: number }): Promise<SessionReplayAllEventsResult> {
const response = await this._interface.getSessionReplayEvents(sessionReplayId, options);
return {
chunks: response.chunks.map((c) => ({
id: c.id,
batchId: c.batch_id,
sessionReplaySegmentId: c.session_replay_segment_id,
eventCount: c.event_count,
byteLength: c.byte_length,
firstEventAt: new Date(c.first_event_at_millis),
lastEventAt: new Date(c.last_event_at_millis),
createdAt: new Date(c.created_at_millis),
})),
chunkEvents: response.chunk_events.map((ce) => ({
chunkId: ce.chunk_id,
events: ce.events,
})),
};
}
async previewAffectedUsersByOnboardingChange(
onboarding: { requireEmailVerification?: boolean },
limit?: number,
): Promise<{
affectedUsers: Array<{
id: string,
displayName: string | null,
primaryEmail: string | null,
restrictedReason: RestrictedReason,
}>,
totalAffectedCount: number,
}> {
const result = await this._interface.previewAffectedUsersByOnboardingChange(
{ require_email_verification: onboarding.requireEmailVerification },
limit,
);
return {
affectedUsers: result.affected_users.map(u => ({
id: u.id,
displayName: u.display_name,
primaryEmail: u.primary_email,
restrictedReason: u.restricted_reason as RestrictedReason,
})),
totalAffectedCount: result.total_affected_count,
};
}
}