latest
This commit is contained in:
@@ -7,10 +7,16 @@
|
||||
// ENV_FILE=.env infisical run --env=dev --recursive -- \
|
||||
// bun apps/leaf/scripts/seedSlackInstall.ts (DATABASE_URL=<worktree>)
|
||||
//
|
||||
// Needs SLACK_BOT_TOKEN (the app's Bot User OAuth Token, xoxb-…) in the env —
|
||||
// skips cleanly if absent.
|
||||
// Needs SLACK_BOT_TOKEN (the app's Bot User OAuth Token, xoxb-…) in the env.
|
||||
// SLACK_CLIENT_ID / SLACK_CLIENT_SECRET configure OAuth, but cannot mint a bot
|
||||
// token without an install callback code, so this skips cleanly if absent.
|
||||
import crypto from "node:crypto";
|
||||
import { AppEnv, type ChatInstallState, member, organizations } from "@autumn/shared";
|
||||
import {
|
||||
AppEnv,
|
||||
type ChatInstallState,
|
||||
member,
|
||||
organizations,
|
||||
} from "@autumn/shared";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../src/lib/db.js";
|
||||
import { replaceInstallation } from "../src/providers/slack/installations.js";
|
||||
@@ -44,7 +50,9 @@ const log = (message: string) => console.log(`[seed-slack] ${message}`);
|
||||
const main = async () => {
|
||||
const botToken = process.env.SLACK_BOT_TOKEN;
|
||||
if (!botToken) {
|
||||
log("skipping: SLACK_BOT_TOKEN not set");
|
||||
log(
|
||||
"skipping: SLACK_BOT_TOKEN not set (client id/secret are not enough to seed an installed bot)",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -82,7 +90,9 @@ const main = async () => {
|
||||
orgId = byId?.id;
|
||||
}
|
||||
if (!orgId) {
|
||||
log(`skipping: org '${SEED_ORG_SLUG}' not found (run the test-org seed first)`);
|
||||
log(
|
||||
`skipping: org '${SEED_ORG_SLUG}' not found (run the test-org seed first)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -39,11 +39,15 @@ const viteAppEnv = envFile.includes(".env.prod")
|
||||
const useLocalAuthUrls = viteAppEnv === "dev" && !isProductionMode;
|
||||
const localUrl = (value: string | undefined, fallback: string) =>
|
||||
value && !value.includes(".useautumn.com") ? value : fallback;
|
||||
const slackRedirectFromPublicTunnel = publicTunnelUrl
|
||||
? `${publicTunnelUrl}/slack/oauth/callback`
|
||||
: undefined;
|
||||
const SLACK_REDIRECT_URI = useLocalAuthUrls
|
||||
? localUrl(
|
||||
? (slackRedirectFromPublicTunnel ??
|
||||
localUrl(
|
||||
process.env.SLACK_REDIRECT_URI,
|
||||
`${SLACK_BOT_URL}/slack/oauth/callback`,
|
||||
)
|
||||
))
|
||||
: (process.env.SLACK_REDIRECT_URI ?? `${SLACK_BOT_URL}/slack/oauth/callback`);
|
||||
|
||||
/**
|
||||
@@ -202,7 +206,9 @@ async function startDev() {
|
||||
// Use the locally-installed (pinned) trigger.dev CLI. Passing
|
||||
// `@<version>` makes bunx fetch a fresh copy into a temp dir,
|
||||
// which can be broken/incomplete (ERR_MODULE_NOT_FOUND).
|
||||
cmds.push(isWindows ? `"bunx trigger.dev dev"` : `"bunx trigger.dev dev"`);
|
||||
cmds.push(
|
||||
isWindows ? `"bunx trigger.dev dev"` : `"bunx trigger.dev dev"`,
|
||||
);
|
||||
}
|
||||
|
||||
names.push("vite", "checkout");
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { log, fatal, shInherit } from "./shell.ts";
|
||||
import { NEON_TEMPLATE_BRANCH, PROJECT_ROOT } from "../constants.ts";
|
||||
import type { RegistryEntry } from "../types.ts";
|
||||
import { applyCommittedMigrations, loadDbFunctions } from "./migration.ts";
|
||||
import {
|
||||
ensureTemplateBranch,
|
||||
createBranch,
|
||||
connectionString,
|
||||
createBranch,
|
||||
ensureTemplateBranch,
|
||||
findBranchByName,
|
||||
} from "./neon.ts";
|
||||
import { applyCommittedMigrations, loadDbFunctions } from "./migration.ts";
|
||||
import { loadRegistry, saveRegistry } from "./registry.ts";
|
||||
import { PROJECT_ROOT, NEON_TEMPLATE_BRANCH } from "../constants.ts";
|
||||
import type { RegistryEntry } from "../types.ts";
|
||||
import { saveRegistry } from "./registry.ts";
|
||||
import { fatal, log, shInherit } from "./shell.ts";
|
||||
|
||||
export async function setupAgentWorktree(
|
||||
entry: RegistryEntry,
|
||||
@@ -61,18 +61,14 @@ export async function autoSetupTestOrg(entry: RegistryEntry): Promise<void> {
|
||||
return;
|
||||
}
|
||||
log(`seeding unit test org in ${entry.branchName ?? "worktree"}`);
|
||||
const code = shInherit(
|
||||
"bun",
|
||||
["scripts/setup/setup-test.ts", "--yes"],
|
||||
{
|
||||
cwd: PROJECT_ROOT,
|
||||
env: {
|
||||
...(process.env as Record<string, string>),
|
||||
DATABASE_URL: entry.databaseUrl,
|
||||
DATABASE_CRITICAL_URL: entry.databaseUrl,
|
||||
},
|
||||
const code = shInherit("bun", ["scripts/setup/setup-test.ts", "--yes"], {
|
||||
cwd: PROJECT_ROOT,
|
||||
env: {
|
||||
...(process.env as Record<string, string>),
|
||||
DATABASE_URL: entry.databaseUrl,
|
||||
DATABASE_CRITICAL_URL: entry.databaseUrl,
|
||||
},
|
||||
);
|
||||
});
|
||||
if (code !== 0) {
|
||||
console.error(`[dw] setup-test exited with code ${code}; continuing`);
|
||||
}
|
||||
@@ -80,30 +76,32 @@ export async function autoSetupTestOrg(entry: RegistryEntry): Promise<void> {
|
||||
|
||||
// Seed the Slack `chat_installations` row (+ OAuth creds) for the worktree's test
|
||||
// org so the dev Slack app works without a manual OAuth install per worktree.
|
||||
// Needs SLACK_BOT_TOKEN (the app's Bot User OAuth Token); skips otherwise.
|
||||
// Needs SLACK_BOT_TOKEN (the app's Bot User OAuth Token). SLACK_CLIENT_ID /
|
||||
// SLACK_CLIENT_SECRET configure OAuth, but cannot mint a bot token without an
|
||||
// install callback code; skips otherwise.
|
||||
// Non-fatal, like autoSetupTestOrg.
|
||||
export async function autoSeedSlackInstall(entry: RegistryEntry): Promise<void> {
|
||||
export async function autoSeedSlackInstall(
|
||||
entry: RegistryEntry,
|
||||
): Promise<void> {
|
||||
if (!entry.databaseUrl) {
|
||||
log("autoSeedSlackInstall: no databaseUrl on entry, skipping");
|
||||
return;
|
||||
}
|
||||
if (!process.env.SLACK_BOT_TOKEN) {
|
||||
log("autoSeedSlackInstall: SLACK_BOT_TOKEN not set, skipping");
|
||||
log(
|
||||
"autoSeedSlackInstall: SLACK_BOT_TOKEN not set, skipping (client id/secret are not enough to seed an installed bot)",
|
||||
);
|
||||
return;
|
||||
}
|
||||
log(`seeding slack installation in ${entry.branchName ?? "worktree"}`);
|
||||
const code = shInherit(
|
||||
"bun",
|
||||
["apps/leaf/scripts/seedSlackInstall.ts"],
|
||||
{
|
||||
cwd: PROJECT_ROOT,
|
||||
env: {
|
||||
...(process.env as Record<string, string>),
|
||||
DATABASE_URL: entry.databaseUrl,
|
||||
DATABASE_CRITICAL_URL: entry.databaseUrl,
|
||||
},
|
||||
const code = shInherit("bun", ["apps/leaf/scripts/seedSlackInstall.ts"], {
|
||||
cwd: PROJECT_ROOT,
|
||||
env: {
|
||||
...(process.env as Record<string, string>),
|
||||
DATABASE_URL: entry.databaseUrl,
|
||||
DATABASE_CRITICAL_URL: entry.databaseUrl,
|
||||
},
|
||||
);
|
||||
});
|
||||
if (code !== 0) {
|
||||
console.error(`[dw] seedSlackInstall exited with code ${code}; continuing`);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import "dotenv/config";
|
||||
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import chalk from "chalk";
|
||||
import inquirer from "inquirer";
|
||||
@@ -29,7 +36,11 @@ const readEnvVarFromFile = ({
|
||||
// the dw registry. Prefer those over process.env.NGROK_URL, which Infisical
|
||||
// injects as a single shared dev tunnel — otherwise every worktree's Slack app
|
||||
// would be pointed at the same URL instead of its own.
|
||||
const resolveWorktreeNgrokUrl = (): string | undefined => {
|
||||
const resolveWorktreeNgrokUrl = ({
|
||||
includeEnvFallback = true,
|
||||
}: {
|
||||
includeEnvFallback?: boolean;
|
||||
} = {}): string | undefined => {
|
||||
const fromEnvFile = readEnvVarFromFile({
|
||||
filePath: join(repoRoot, "server", ".env.local"),
|
||||
key: "NGROK_URL",
|
||||
@@ -39,10 +50,9 @@ const resolveWorktreeNgrokUrl = (): string | undefined => {
|
||||
try {
|
||||
const registryPath = join(homedir(), ".autumn-worktrees.json");
|
||||
if (existsSync(registryPath)) {
|
||||
const registry = JSON.parse(readFileSync(registryPath, "utf-8")) as Record<
|
||||
string,
|
||||
{ ngrokUrl?: string }
|
||||
>;
|
||||
const registry = JSON.parse(
|
||||
readFileSync(registryPath, "utf-8"),
|
||||
) as Record<string, { ngrokUrl?: string }>;
|
||||
const entry = registry[repoRoot];
|
||||
if (entry?.ngrokUrl) return entry.ngrokUrl;
|
||||
}
|
||||
@@ -50,7 +60,7 @@ const resolveWorktreeNgrokUrl = (): string | undefined => {
|
||||
// Malformed/absent registry — fall through to the shared env value.
|
||||
}
|
||||
|
||||
return process.env.NGROK_URL;
|
||||
return includeEnvFallback ? process.env.NGROK_URL : undefined;
|
||||
};
|
||||
|
||||
const defaultSlackScopes = [
|
||||
@@ -88,15 +98,18 @@ type Args = {
|
||||
dryRun: boolean;
|
||||
envFile?: string;
|
||||
help: boolean;
|
||||
yes: boolean;
|
||||
printManifest: boolean;
|
||||
provider?: SlackInstallProvider;
|
||||
scopes: string[];
|
||||
target?: SlackManifestTarget;
|
||||
teamId?: string;
|
||||
writeInfisicalEnv?: SlackInfisicalWriteEnv;
|
||||
};
|
||||
|
||||
type SlackInstallProvider = "slack" | "slack_admin";
|
||||
type SlackManifestTarget = "local" | "prod" | "admin" | "all";
|
||||
type SlackInfisicalWriteEnv = "dev" | "prod";
|
||||
|
||||
type SlackManifest = {
|
||||
display_information: {
|
||||
@@ -153,6 +166,10 @@ type SlackManifestUpdateResponse = SlackApiResponse & {
|
||||
app_id?: string;
|
||||
};
|
||||
|
||||
type SlackManifestExportResponse = SlackApiResponse & {
|
||||
manifest?: SlackManifest;
|
||||
};
|
||||
|
||||
type SlackApiResponse = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
@@ -167,32 +184,46 @@ const usage = () =>
|
||||
" bun slack <command> [options]",
|
||||
"",
|
||||
chalk.bold("Commands:"),
|
||||
` ${chalk.cyan("worktree")} Repoint the local Slack app's chat (/slack/events),`,
|
||||
` ${chalk.cyan("worktree")} Repoint known local Slack app chat (/slack/events),`,
|
||||
" approval (/slack/interactions) and OAuth URLs at THIS",
|
||||
" worktree's ngrok tunnel. Base URL is auto-read from",
|
||||
" server/.env.local / the dw registry — no --base-url needed.",
|
||||
` ${chalk.cyan("setup-bot")} Interactively create a NEW Slack app (manifest) for`,
|
||||
" local dev and print its credentials. Prompts for a",
|
||||
" regular org bot or an admin impersonation bot.",
|
||||
` ${chalk.cyan("setup-local-bot")} Create a NEW regular local Slack app and write only`,
|
||||
" the local Slack bot env vars as Infisical dev",
|
||||
" personal overrides.",
|
||||
` ${chalk.cyan("setup-local-admin-bot")} Create a NEW local admin Slack app and, after`,
|
||||
" confirmation, overwrite only the Slack bot env vars",
|
||||
" as Infisical prod personal overrides.",
|
||||
` ${chalk.cyan("update-manifest")} Update an EXISTING Slack app's manifest. Choose the`,
|
||||
" app(s) with --target <local|prod|admin|all>.",
|
||||
"",
|
||||
chalk.bold("Options:"),
|
||||
" --app-id <id> Existing Slack app id for manifest updates.",
|
||||
" --base-url <url> Public Leaf URL. Defaults to this worktree's NGROK_URL,",
|
||||
" then SLACK_BOT_URL / CHAT_URL.",
|
||||
" --base-url <url> Public Leaf URL. Local setup defaults to this",
|
||||
" worktree's NGROK_URL only; manifest updates can",
|
||||
" also fall back to SLACK_BOT_URL / CHAT_URL.",
|
||||
" --name <name> Slack app name. Defaults to Autumn Chat Local.",
|
||||
" --env-file <path> Write Slack env vars to this file.",
|
||||
" --provider <provider> slack or slack_admin (setup-bot only).",
|
||||
" --scopes <csv> Override bot scopes.",
|
||||
" --target <target> update-manifest target: local, prod, admin, or all.",
|
||||
" --team-id <id> Workspace team id for org-scoped Slack CLI auth.",
|
||||
" --no-infisical Do not write setup-local-bot vars to Infisical.",
|
||||
" --infisical-dev Write local Slack bot vars as Infisical dev personal overrides.",
|
||||
" --infisical-prod Write local Slack bot vars as Infisical prod personal overrides.",
|
||||
" --yes Skip confirmation prompts for Infisical writes.",
|
||||
" Ignored for setup-local-admin-bot prod writes.",
|
||||
" --print-manifest Print generated Slack app manifest.",
|
||||
" --dry-run Print manifest/env without calling Slack.",
|
||||
" --help Show this help.",
|
||||
"",
|
||||
chalk.bold("Examples:"),
|
||||
" bun slack worktree",
|
||||
" bun slack setup-local-bot",
|
||||
" bun slack setup-local-admin-bot",
|
||||
" bun slack setup-bot --provider slack_admin",
|
||||
" bun slack update-manifest --target all --base-url https://j.dev.useautumn.com",
|
||||
].join("\n");
|
||||
@@ -219,10 +250,20 @@ const parseArgs = ({ argv }: { argv: string[] }): Args => {
|
||||
const scopes = readOption({ args: argv, name: "--scopes" });
|
||||
const providerArg = readOption({ args: argv, name: "--provider" });
|
||||
const targetArg = readOption({ args: argv, name: "--target" });
|
||||
const isLocalSetupAction =
|
||||
action === "setup-local-bot" || action === "setup-local-admin-bot";
|
||||
const baseUrlOption = readOption({ args: argv, name: "--base-url" });
|
||||
const writeInfisicalEnv = argv.includes("--no-infisical")
|
||||
? undefined
|
||||
: argv.includes("--infisical-prod") || action === "setup-local-admin-bot"
|
||||
? "prod"
|
||||
: argv.includes("--infisical-dev") || action === "setup-local-bot"
|
||||
? "dev"
|
||||
: undefined;
|
||||
const provider =
|
||||
providerArg === "slack" || providerArg === "slack_admin"
|
||||
? providerArg
|
||||
: action === "setup-admin-bot"
|
||||
: action === "setup-admin-bot" || action === "setup-local-admin-bot"
|
||||
? "slack_admin"
|
||||
: action === "setup-local-bot" || action === "setup-regular-bot"
|
||||
? "slack"
|
||||
@@ -237,13 +278,17 @@ const parseArgs = ({ argv }: { argv: string[] }): Args => {
|
||||
appId: readOption({ args: argv, name: "--app-id" }),
|
||||
appName: readOption({ args: argv, name: "--name" }) ?? defaultAppName,
|
||||
baseUrl:
|
||||
readOption({ args: argv, name: "--base-url" }) ??
|
||||
resolveWorktreeNgrokUrl() ??
|
||||
process.env.SLACK_BOT_URL ??
|
||||
process.env.CHAT_URL,
|
||||
baseUrlOption ??
|
||||
resolveWorktreeNgrokUrl({
|
||||
includeEnvFallback: !isLocalSetupAction,
|
||||
}) ??
|
||||
(isLocalSetupAction
|
||||
? undefined
|
||||
: (process.env.SLACK_BOT_URL ?? process.env.CHAT_URL)),
|
||||
dryRun: argv.includes("--dry-run"),
|
||||
envFile: readOption({ args: argv, name: "--env-file" }),
|
||||
help: argv.includes("--help") || argv.includes("-h"),
|
||||
yes: argv.includes("--yes") || argv.includes("-y"),
|
||||
printManifest: argv.includes("--print-manifest"),
|
||||
provider,
|
||||
scopes: scopes
|
||||
@@ -265,6 +310,7 @@ const parseArgs = ({ argv }: { argv: string[] }): Args => {
|
||||
? "all"
|
||||
: undefined,
|
||||
teamId: readOption({ args: argv, name: "--team-id" }),
|
||||
writeInfisicalEnv,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -291,6 +337,9 @@ const resolveInteractiveArgs = async ({
|
||||
}: {
|
||||
args: Args;
|
||||
}): Promise<Args & { provider: SlackInstallProvider }> => {
|
||||
const isLocalSetupAction =
|
||||
args.action === "setup-local-bot" ||
|
||||
args.action === "setup-local-admin-bot";
|
||||
const answers = await inquirer.prompt<{
|
||||
provider?: SlackInstallProvider;
|
||||
appName?: string;
|
||||
@@ -317,16 +366,19 @@ const resolveInteractiveArgs = async ({
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
type: "input",
|
||||
name: "appName",
|
||||
message: "Slack app name",
|
||||
default: ({ provider }: { provider?: SlackInstallProvider }) =>
|
||||
args.appName ??
|
||||
defaultAppNameForProvider({
|
||||
provider: provider ?? args.provider ?? "slack",
|
||||
}),
|
||||
},
|
||||
...(!args.appName
|
||||
? [
|
||||
{
|
||||
type: "input" as const,
|
||||
name: "appName" as const,
|
||||
message: "Slack app name",
|
||||
default: ({ provider }: { provider?: SlackInstallProvider }) =>
|
||||
defaultAppNameForProvider({
|
||||
provider: provider ?? args.provider ?? "slack",
|
||||
}),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!args.baseUrl
|
||||
? [
|
||||
{
|
||||
@@ -334,16 +386,19 @@ const resolveInteractiveArgs = async ({
|
||||
name: "baseUrl" as const,
|
||||
message: "Public ngrok/Leaf URL",
|
||||
default:
|
||||
resolveWorktreeNgrokUrl() ??
|
||||
process.env.SLACK_BOT_URL ??
|
||||
process.env.CHAT_URL,
|
||||
resolveWorktreeNgrokUrl({
|
||||
includeEnvFallback: !isLocalSetupAction,
|
||||
}) ??
|
||||
(isLocalSetupAction
|
||||
? undefined
|
||||
: (process.env.SLACK_BOT_URL ?? process.env.CHAT_URL)),
|
||||
filter: (value: string) => trimTrailingSlash({ url: value.trim() }),
|
||||
validate: (value: string) =>
|
||||
isUrl({ value }) || "Enter a valid http(s) URL",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!args.envFile
|
||||
...(!args.envFile && !args.writeInfisicalEnv
|
||||
? [
|
||||
{
|
||||
type: "input" as const,
|
||||
@@ -479,6 +534,80 @@ const parseSlackJson = <T>({
|
||||
// Config tokens come from https://api.slack.com/apps → "Your App Configuration
|
||||
// Tokens". Generate once, set SLACK_CONFIG_REFRESH_TOKEN, and we self-rotate.
|
||||
const configTokenStorePath = join(homedir(), ".autumn-slack-config.json");
|
||||
const localSlackAppsStorePath = join(homedir(), ".autumn-slack-apps.json");
|
||||
|
||||
type StoredLocalSlackApp = {
|
||||
appId: string;
|
||||
appName: string;
|
||||
clientId: string;
|
||||
provider: SlackInstallProvider;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
const readStoredLocalSlackApps = (): StoredLocalSlackApp[] => {
|
||||
if (!existsSync(localSlackAppsStorePath)) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
readFileSync(localSlackAppsStorePath, "utf-8"),
|
||||
) as { apps?: StoredLocalSlackApp[] };
|
||||
return Array.isArray(parsed.apps) ? parsed.apps : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const writeStoredLocalSlackApps = ({
|
||||
apps,
|
||||
}: {
|
||||
apps: StoredLocalSlackApp[];
|
||||
}) => {
|
||||
writeFileSync(localSlackAppsStorePath, JSON.stringify({ apps }, null, 2));
|
||||
try {
|
||||
chmodSync(localSlackAppsStorePath, 0o600);
|
||||
} catch {
|
||||
// Best-effort file permissions; not fatal where chmod is unavailable.
|
||||
}
|
||||
};
|
||||
|
||||
const rememberLocalSlackApp = ({
|
||||
appId,
|
||||
appName,
|
||||
clientId,
|
||||
provider,
|
||||
}: {
|
||||
appId: string;
|
||||
appName: string;
|
||||
clientId: string;
|
||||
provider: SlackInstallProvider;
|
||||
}) => {
|
||||
const apps = readStoredLocalSlackApps();
|
||||
const withoutExisting = apps.filter(
|
||||
(app) => app.appId !== appId && app.clientId !== clientId,
|
||||
);
|
||||
writeStoredLocalSlackApps({
|
||||
apps: [
|
||||
...withoutExisting,
|
||||
{ appId, appName, clientId, provider, updatedAt: Date.now() },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const resolveStoredLocalSlackAppId = ({
|
||||
clientId,
|
||||
provider,
|
||||
}: {
|
||||
clientId?: string;
|
||||
provider: SlackInstallProvider;
|
||||
}) => {
|
||||
const apps = readStoredLocalSlackApps()
|
||||
.filter((app) => app.provider === provider)
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
return (
|
||||
(clientId
|
||||
? apps.find((app) => app.clientId === clientId)?.appId
|
||||
: undefined) ?? apps[0]?.appId
|
||||
);
|
||||
};
|
||||
|
||||
const readStoredRefreshToken = (): string | undefined => {
|
||||
if (!existsSync(configTokenStorePath)) return undefined;
|
||||
@@ -497,7 +626,10 @@ const writeStoredRefreshToken = ({
|
||||
}: {
|
||||
refreshToken: string;
|
||||
}) => {
|
||||
writeFileSync(configTokenStorePath, JSON.stringify({ refreshToken }, null, 2));
|
||||
writeFileSync(
|
||||
configTokenStorePath,
|
||||
JSON.stringify({ refreshToken }, null, 2),
|
||||
);
|
||||
try {
|
||||
chmodSync(configTokenStorePath, 0o600);
|
||||
} catch {
|
||||
@@ -641,11 +773,263 @@ const updateSlackAppManifest = async ({
|
||||
return json;
|
||||
};
|
||||
|
||||
const exportSlackAppManifest = async ({
|
||||
appId,
|
||||
configToken,
|
||||
teamId,
|
||||
}: {
|
||||
appId: string;
|
||||
configToken?: string;
|
||||
teamId?: string;
|
||||
}): Promise<SlackManifest | undefined> => {
|
||||
const output = runSlackCli({
|
||||
args: [
|
||||
"api",
|
||||
"apps.manifest.export",
|
||||
...(configToken ? ["--token", configToken] : []),
|
||||
"--json",
|
||||
JSON.stringify({
|
||||
app_id: appId,
|
||||
...(teamId ? { team_id: teamId } : {}),
|
||||
}),
|
||||
],
|
||||
quiet: true,
|
||||
});
|
||||
const json = parseSlackJson<SlackManifestExportResponse>({
|
||||
output,
|
||||
label: "apps.manifest.export",
|
||||
});
|
||||
if (!json.ok)
|
||||
throw new Error(`Slack app manifest export failed: ${json.error}`);
|
||||
return json.manifest;
|
||||
};
|
||||
|
||||
const preserveSlackAppManifestNames = async ({
|
||||
appId,
|
||||
configToken,
|
||||
manifest,
|
||||
teamId,
|
||||
}: {
|
||||
appId: string;
|
||||
configToken?: string;
|
||||
manifest: SlackManifest;
|
||||
teamId?: string;
|
||||
}): Promise<SlackManifest> => {
|
||||
const currentManifest = await exportSlackAppManifest({
|
||||
appId,
|
||||
configToken,
|
||||
teamId,
|
||||
});
|
||||
if (!currentManifest) return manifest;
|
||||
|
||||
return {
|
||||
...manifest,
|
||||
display_information:
|
||||
currentManifest.display_information ?? manifest.display_information,
|
||||
features: {
|
||||
...manifest.features,
|
||||
bot_user: {
|
||||
...manifest.features.bot_user,
|
||||
display_name:
|
||||
currentManifest.features?.bot_user?.display_name ??
|
||||
manifest.features.bot_user.display_name,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const escapeEnvValue = ({ value }: { value: string }) => {
|
||||
if (/^[A-Za-z0-9_./:@-]+$/.test(value)) return value;
|
||||
return JSON.stringify(value);
|
||||
};
|
||||
|
||||
const localSlackInfisicalKeys = [
|
||||
"SLACK_APP_ID",
|
||||
"SLACK_CLIENT_ID",
|
||||
"SLACK_CLIENT_SECRET",
|
||||
"SLACK_SIGNING_SECRET",
|
||||
] as const;
|
||||
|
||||
const slackInfisicalSecretPath = "/leaf";
|
||||
const slackInfisicalSecretType = "personal";
|
||||
|
||||
const localSlackInfisicalKeySet = new Set<string>(localSlackInfisicalKeys);
|
||||
|
||||
const assertSafeInfisicalSlackWrite = ({
|
||||
vars,
|
||||
}: {
|
||||
vars: Record<string, string>;
|
||||
}) => {
|
||||
const keys = Object.keys(vars);
|
||||
const unexpectedKeys = keys.filter(
|
||||
(key) => !localSlackInfisicalKeySet.has(key),
|
||||
);
|
||||
if (unexpectedKeys.length > 0) {
|
||||
throw new Error(
|
||||
[
|
||||
"Refusing to write unexpected Infisical Slack keys.",
|
||||
`Allowed keys: ${localSlackInfisicalKeys.join(", ")}`,
|
||||
`Unexpected keys: ${unexpectedKeys.join(", ")}`,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const redactValues = ({
|
||||
output,
|
||||
values,
|
||||
}: {
|
||||
output: string;
|
||||
values: string[];
|
||||
}) => {
|
||||
let redacted = output;
|
||||
for (const value of values) {
|
||||
if (!value) continue;
|
||||
redacted = redacted.replaceAll(value, "<redacted>");
|
||||
}
|
||||
return redacted;
|
||||
};
|
||||
|
||||
const formatEnvFile = ({ vars }: { vars: Record<string, string> }) =>
|
||||
Object.entries(vars)
|
||||
.map(([key, value]) => `${key}=${escapeEnvValue({ value })}`)
|
||||
.join("\n");
|
||||
|
||||
const verifyInfisicalSecretsNonEmpty = ({
|
||||
env,
|
||||
keys,
|
||||
}: {
|
||||
env: SlackInfisicalWriteEnv;
|
||||
keys: string[];
|
||||
}) => {
|
||||
const missingKeys: string[] = [];
|
||||
for (const key of keys) {
|
||||
const result = Bun.spawnSync(
|
||||
[
|
||||
"infisical",
|
||||
"secrets",
|
||||
"get",
|
||||
key,
|
||||
`--env=${env}`,
|
||||
`--path=${slackInfisicalSecretPath}`,
|
||||
"--plain",
|
||||
"--silent",
|
||||
],
|
||||
{
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
);
|
||||
const value = new TextDecoder().decode(result.stdout).trim();
|
||||
if (result.exitCode !== 0 || !value) {
|
||||
missingKeys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingKeys.length > 0) {
|
||||
throw new Error(
|
||||
[
|
||||
`Infisical ${env}${slackInfisicalSecretPath} write verification failed.`,
|
||||
`Missing or empty keys: ${missingKeys.join(", ")}`,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const syncInfisicalLocalSlackSecrets = async ({
|
||||
env,
|
||||
forceConfirmation,
|
||||
provider,
|
||||
vars,
|
||||
yes,
|
||||
}: {
|
||||
env: SlackInfisicalWriteEnv;
|
||||
forceConfirmation: boolean;
|
||||
provider: SlackInstallProvider;
|
||||
vars: Record<string, string>;
|
||||
yes: boolean;
|
||||
}) => {
|
||||
assertSafeInfisicalSlackWrite({ vars });
|
||||
if (env === "prod" && provider !== "slack_admin") {
|
||||
throw new Error(
|
||||
"Refusing to write Infisical prod Slack vars for a non-admin local bot",
|
||||
);
|
||||
}
|
||||
|
||||
const keys = Object.keys(vars);
|
||||
if (forceConfirmation || !yes) {
|
||||
const answer = await inquirer.prompt<{ confirmed: boolean }>([
|
||||
{
|
||||
type: "confirm",
|
||||
name: "confirmed",
|
||||
default: false,
|
||||
message: [
|
||||
`Overwrite Infisical ${env.toUpperCase()} personal overrides for the ${provider === "slack_admin" ? "local admin" : "local"} Slack bot?`,
|
||||
`Path: ${slackInfisicalSecretPath}`,
|
||||
`Only these keys will be written: ${keys.join(", ")}`,
|
||||
].join("\n"),
|
||||
},
|
||||
]);
|
||||
if (!answer.confirmed) {
|
||||
throw new Error(`Cancelled Infisical ${env} secret overwrite`);
|
||||
}
|
||||
}
|
||||
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "autumn-slack-infisical-"));
|
||||
const tempFile = join(tempDir, "slack.env");
|
||||
try {
|
||||
writeFileSync(tempFile, `${formatEnvFile({ vars })}\n`);
|
||||
try {
|
||||
chmodSync(tempFile, 0o600);
|
||||
} catch {
|
||||
// Best-effort file permissions; the temp dir is still removed below.
|
||||
}
|
||||
|
||||
const result = Bun.spawnSync(
|
||||
[
|
||||
"infisical",
|
||||
"secrets",
|
||||
"set",
|
||||
`--env=${env}`,
|
||||
`--path=${slackInfisicalSecretPath}`,
|
||||
`--type=${slackInfisicalSecretType}`,
|
||||
"--silent",
|
||||
"--file",
|
||||
tempFile,
|
||||
],
|
||||
{
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
);
|
||||
const stdout = new TextDecoder().decode(result.stdout).trim();
|
||||
const stderr = new TextDecoder().decode(result.stderr).trim();
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
const values = Object.values(vars);
|
||||
throw new Error(
|
||||
[
|
||||
`infisical secrets set failed for ${env} keys: ${keys.join(", ")}`,
|
||||
stdout ? redactValues({ output: stdout, values }) : undefined,
|
||||
stderr ? redactValues({ output: stderr, values }) : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
verifyInfisicalSecretsNonEmpty({ env, keys });
|
||||
} finally {
|
||||
rmSync(tempDir, { force: true, recursive: true });
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.green(
|
||||
`Overwrote local Slack bot personal overrides in Infisical ${env}${slackInfisicalSecretPath}: ${keys.join(", ")}`,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const upsertEnvFile = ({
|
||||
filePath,
|
||||
vars,
|
||||
@@ -690,6 +1074,11 @@ const setupSlackBot = async ({ args }: { args: Args }) => {
|
||||
const provider = resolvedArgs.provider;
|
||||
const baseUrl = resolvedArgs.baseUrl;
|
||||
if (!baseUrl) throw new Error("Missing public Leaf URL");
|
||||
if (resolvedArgs.writeInfisicalEnv === "prod" && provider !== "slack_admin") {
|
||||
throw new Error(
|
||||
"Refusing to write Infisical prod Slack vars for a non-admin local bot",
|
||||
);
|
||||
}
|
||||
const readyLabel =
|
||||
provider === "slack_admin"
|
||||
? "Slack admin app ready"
|
||||
@@ -728,6 +1117,7 @@ const setupSlackBot = async ({ args }: { args: Args }) => {
|
||||
const clientSecret = credentials?.client_secret;
|
||||
const signingSecret = credentials?.signing_secret;
|
||||
const redirectUrl = manifest.oauth_config.redirect_urls[0];
|
||||
const appId = slackResponse?.app_id;
|
||||
|
||||
if (!resolvedArgs.dryRun && (!clientId || !clientSecret || !signingSecret)) {
|
||||
console.log(
|
||||
@@ -736,13 +1126,21 @@ const setupSlackBot = async ({ args }: { args: Args }) => {
|
||||
console.log(JSON.stringify(slackResponse, null, 2));
|
||||
throw new Error("Could not extract Slack app credentials from response");
|
||||
}
|
||||
|
||||
const envVars = {
|
||||
SLACK_APP_ID: appId ?? "<app-id-from-slack>",
|
||||
SLACK_CLIENT_ID: clientId ?? "<client-id-from-slack>",
|
||||
SLACK_CLIENT_SECRET: clientSecret ?? "<client-secret-from-slack>",
|
||||
SLACK_SIGNING_SECRET: signingSecret ?? "<signing-secret-from-slack>",
|
||||
SLACK_REDIRECT_URI: redirectUrl,
|
||||
};
|
||||
if (!resolvedArgs.dryRun && appId && clientId) {
|
||||
rememberLocalSlackApp({
|
||||
appId,
|
||||
appName: manifest.display_information.name,
|
||||
clientId,
|
||||
provider,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(chalk.green(`\n${readyLabel}`));
|
||||
if (slackResponse?.app_id) console.log(`App ID: ${slackResponse.app_id}`);
|
||||
@@ -755,6 +1153,30 @@ const setupSlackBot = async ({ args }: { args: Args }) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (resolvedArgs.writeInfisicalEnv) {
|
||||
const infisicalEnvVars = {
|
||||
...(provider === "slack" ? { SLACK_APP_ID: envVars.SLACK_APP_ID } : {}),
|
||||
SLACK_CLIENT_ID: envVars.SLACK_CLIENT_ID,
|
||||
SLACK_CLIENT_SECRET: envVars.SLACK_CLIENT_SECRET,
|
||||
SLACK_SIGNING_SECRET: envVars.SLACK_SIGNING_SECRET,
|
||||
};
|
||||
if (resolvedArgs.dryRun) {
|
||||
console.log(
|
||||
chalk.cyan(
|
||||
`\nDry run: would overwrite Infisical ${resolvedArgs.writeInfisicalEnv}${slackInfisicalSecretPath} personal override keys: ${Object.keys(infisicalEnvVars).join(", ")}`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await syncInfisicalLocalSlackSecrets({
|
||||
env: resolvedArgs.writeInfisicalEnv,
|
||||
forceConfirmation: resolvedArgs.writeInfisicalEnv === "prod",
|
||||
provider,
|
||||
vars: infisicalEnvVars,
|
||||
yes: resolvedArgs.yes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (slackResponse?.oauth_authorize_url) {
|
||||
console.log(
|
||||
chalk.gray(
|
||||
@@ -771,6 +1193,11 @@ const setupAdminBot = async ({ args }: { args: Args }) =>
|
||||
const setupLocalBot = async ({ args }: { args: Args }) =>
|
||||
setupSlackBot({ args: { ...args, provider: "slack" } });
|
||||
|
||||
const setupLocalAdminBot = async ({ args }: { args: Args }) =>
|
||||
setupSlackBot({
|
||||
args: { ...args, provider: "slack_admin" },
|
||||
});
|
||||
|
||||
const prodBaseUrl = "https://api.useautumn.com";
|
||||
|
||||
const targetDefaults = ({
|
||||
@@ -788,7 +1215,12 @@ const targetDefaults = ({
|
||||
}
|
||||
if (target === "admin") {
|
||||
return {
|
||||
appId: process.env.SLACK_ADMIN_APP_IDS ?? process.env.SLACK_ADMIN_APP_ID,
|
||||
appId:
|
||||
process.env.SLACK_ADMIN_APP_IDS ??
|
||||
process.env.SLACK_ADMIN_APP_ID ??
|
||||
resolveStoredLocalSlackAppId({
|
||||
provider: "slack_admin",
|
||||
}),
|
||||
appName: process.env.SLACK_ADMIN_APP_NAME ?? "Autumn Chat Admin Local",
|
||||
baseUrl:
|
||||
resolveWorktreeNgrokUrl() ??
|
||||
@@ -798,7 +1230,13 @@ const targetDefaults = ({
|
||||
};
|
||||
}
|
||||
return {
|
||||
appId: process.env.SLACK_APP_ID ?? process.env.SLACK_LOCAL_APP_ID,
|
||||
appId:
|
||||
process.env.SLACK_APP_ID ??
|
||||
process.env.SLACK_LOCAL_APP_ID ??
|
||||
resolveStoredLocalSlackAppId({
|
||||
clientId: process.env.SLACK_CLIENT_ID,
|
||||
provider: "slack",
|
||||
}),
|
||||
appName: process.env.SLACK_APP_NAME ?? "Autumn Chat Local",
|
||||
baseUrl:
|
||||
resolveWorktreeNgrokUrl() ??
|
||||
@@ -861,10 +1299,23 @@ const resolveManifestUpdateTarget = async ({
|
||||
|
||||
const updateManifestTargets = async ({ args }: { args: Args }) => {
|
||||
const target = args.target ?? "local";
|
||||
const targets =
|
||||
const targets: Exclude<SlackManifestTarget, "all">[] =
|
||||
target === "all"
|
||||
? (["local", "prod", "admin"] as const)
|
||||
: ([target] as Exclude<SlackManifestTarget, "all">[]);
|
||||
: args.action === "worktree" && target === "local" && !args.appId
|
||||
? (["local", "admin"] as const).filter((worktreeTarget) =>
|
||||
Boolean(targetDefaults({ target: worktreeTarget }).appId),
|
||||
)
|
||||
: ([target] as Exclude<SlackManifestTarget, "all">[]);
|
||||
|
||||
if (targets.length === 0) {
|
||||
throw new Error(
|
||||
[
|
||||
"No local Slack app ids found for worktree manifest update.",
|
||||
"Run setup-local-bot/setup-local-admin-bot first, or pass --app-id <id>.",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
if (!args.dryRun) {
|
||||
ensureSlackCli();
|
||||
@@ -893,6 +1344,13 @@ const updateManifestTargets = async ({ args }: { args: Args }) => {
|
||||
|
||||
if (args.printManifest || args.dryRun) {
|
||||
console.log(chalk.cyan(`\n${updateTarget} Slack app manifest:`));
|
||||
if (args.action === "worktree") {
|
||||
console.log(
|
||||
chalk.gray(
|
||||
"Worktree updates preserve the existing Slack app name on real updates.",
|
||||
),
|
||||
);
|
||||
}
|
||||
console.log(JSON.stringify(manifest, null, 2));
|
||||
}
|
||||
|
||||
@@ -901,9 +1359,18 @@ const updateManifestTargets = async ({ args }: { args: Args }) => {
|
||||
throw new Error(`Missing Slack app id for ${updateTarget} manifest`);
|
||||
}
|
||||
for (const appId of resolved.appIds) {
|
||||
const manifestForApp =
|
||||
args.action === "worktree"
|
||||
? await preserveSlackAppManifestNames({
|
||||
appId,
|
||||
configToken,
|
||||
manifest,
|
||||
teamId: args.teamId,
|
||||
})
|
||||
: manifest;
|
||||
await updateSlackAppManifest({
|
||||
appId,
|
||||
manifest,
|
||||
manifest: manifestForApp,
|
||||
configToken,
|
||||
teamId: args.teamId,
|
||||
});
|
||||
@@ -917,6 +1384,7 @@ const updateManifestTargets = async ({ args }: { args: Args }) => {
|
||||
const actions = {
|
||||
"setup-bot": setupSlackBot,
|
||||
"setup-admin-bot": setupAdminBot,
|
||||
"setup-local-admin-bot": setupLocalAdminBot,
|
||||
"setup-local-bot": setupLocalBot,
|
||||
"setup-regular-bot": setupLocalBot,
|
||||
"update-admin-manifest": updateManifestTargets,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import type { ApiCustomerV5 } from "@autumn/shared";
|
||||
import chalk from "chalk";
|
||||
import {
|
||||
initKnowledgePlatformScenario,
|
||||
knowledgePlatformFeatureIds,
|
||||
seedKnowledgePlatformCustomers,
|
||||
} from "./knowledge-platform";
|
||||
|
||||
test(`${chalk.yellowBright("agent: knowledge platform setup with products, features, and entities")}`, async () => {
|
||||
const { autumnV2_2, customerId, entities, plans } =
|
||||
await initKnowledgePlatformScenario({
|
||||
customerId: "agent-knowledge-platform-smoke",
|
||||
attachPlan: "trial",
|
||||
entityCount: 2,
|
||||
});
|
||||
|
||||
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
const planIds = customer.subscriptions.map(
|
||||
(subscription) => subscription.plan_id,
|
||||
);
|
||||
|
||||
expect(planIds).toContain(plans.trial.id);
|
||||
expect(entities).toHaveLength(2);
|
||||
expect(entities[0].featureId).toBe(knowledgePlatformFeatureIds.workspaces);
|
||||
});
|
||||
|
||||
const seedTest =
|
||||
process.env.SEED_KNOWLEDGE_PLATFORM_CUSTOMERS === "true" ? test : test.skip;
|
||||
|
||||
seedTest(
|
||||
`${chalk.yellowBright("agent: seed knowledge platform org with realistic customers")}`,
|
||||
async () => {
|
||||
const customerCount = Number(
|
||||
process.env.KNOWLEDGE_PLATFORM_CUSTOMER_COUNT ?? "1000",
|
||||
);
|
||||
const result = await seedKnowledgePlatformCustomers({ customerCount });
|
||||
|
||||
expect(result.customerCount).toBe(customerCount);
|
||||
expect(result.entityCount).toBeGreaterThanOrEqual(customerCount);
|
||||
},
|
||||
);
|
||||
478
server/tests/scenarios/agent/knowledge-platform.ts
Normal file
478
server/tests/scenarios/agent/knowledge-platform.ts
Normal file
@@ -0,0 +1,478 @@
|
||||
import {
|
||||
FeatureUsageType,
|
||||
type ProductItem,
|
||||
type ProductV2,
|
||||
} from "@autumn/shared";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import defaultCtx, {
|
||||
createTestContext,
|
||||
type TestContext,
|
||||
} from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import { FeatureService } from "@/internal/features/FeatureService";
|
||||
import {
|
||||
constructBooleanFeature,
|
||||
constructCreditSystem,
|
||||
constructMeteredFeature,
|
||||
} from "@/internal/features/utils/constructFeatureUtils";
|
||||
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem";
|
||||
import {
|
||||
buildRealisticCustomerSeed,
|
||||
createScenarioAutumn,
|
||||
seedCustomersWithEntities,
|
||||
} from "../seedUtils";
|
||||
|
||||
export const knowledgePlatformFeatureIds = {
|
||||
activity_events: "activity_events",
|
||||
approval_chains: "approval_chains",
|
||||
automation_rules: "automation_rules",
|
||||
brand_controls: "brand_controls",
|
||||
compliance_controls: "compliance_controls",
|
||||
credits: "credits",
|
||||
export_center: "export_center",
|
||||
hosted_solution: "hosted_solution",
|
||||
insight_reports: "insight_reports",
|
||||
member_slots: "member_slots",
|
||||
outbound_hooks: "outbound_hooks",
|
||||
platform_api: "platform_api",
|
||||
priority_queue: "priority_queue",
|
||||
private_spaces: "private_spaces",
|
||||
project_slots: "project_slots",
|
||||
revision_history: "revision_history",
|
||||
team_policies: "team_policies",
|
||||
unlimited_seats: "unlimited_seats",
|
||||
workspaces: "workspaces",
|
||||
} as const;
|
||||
|
||||
export const knowledgePlatformPlanIds = {
|
||||
automationPack: "automation_pack",
|
||||
enterprise: "enterprise",
|
||||
launch: "launch",
|
||||
scale: "scale",
|
||||
scaleYearly: "scale_yearly",
|
||||
securityPack: "security_pack",
|
||||
trial: "trial",
|
||||
whiteLabelPack: "white_label_pack",
|
||||
} as const;
|
||||
|
||||
export const knowledgePlatformPlatformFeatureIds = [
|
||||
knowledgePlatformFeatureIds.insight_reports,
|
||||
knowledgePlatformFeatureIds.team_policies,
|
||||
knowledgePlatformFeatureIds.private_spaces,
|
||||
knowledgePlatformFeatureIds.export_center,
|
||||
knowledgePlatformFeatureIds.priority_queue,
|
||||
knowledgePlatformFeatureIds.automation_rules,
|
||||
knowledgePlatformFeatureIds.outbound_hooks,
|
||||
knowledgePlatformFeatureIds.platform_api,
|
||||
knowledgePlatformFeatureIds.approval_chains,
|
||||
knowledgePlatformFeatureIds.brand_controls,
|
||||
knowledgePlatformFeatureIds.compliance_controls,
|
||||
knowledgePlatformFeatureIds.revision_history,
|
||||
] as const;
|
||||
|
||||
export const knowledgePlatformContractFeatureIds = [
|
||||
knowledgePlatformFeatureIds.hosted_solution,
|
||||
knowledgePlatformFeatureIds.unlimited_seats,
|
||||
] as const;
|
||||
|
||||
const featureNames: Partial<Record<KnowledgePlatformFeatureId, string>> = {
|
||||
activity_events: "Activity Events",
|
||||
hosted_solution: "Hosted Solution",
|
||||
unlimited_seats: "Unlimited Seats",
|
||||
workspaces: "Workspaces",
|
||||
};
|
||||
|
||||
type KnowledgePlatformFeatureId =
|
||||
(typeof knowledgePlatformFeatureIds)[keyof typeof knowledgePlatformFeatureIds];
|
||||
|
||||
type KnowledgePlatformPlanMap = ReturnType<
|
||||
typeof buildKnowledgePlatformProducts
|
||||
>["plans"];
|
||||
|
||||
export type KnowledgePlatformPlanKey = keyof KnowledgePlatformPlanMap;
|
||||
|
||||
const booleanItem = (featureId: KnowledgePlatformFeatureId): ProductItem =>
|
||||
constructFeatureItem({
|
||||
featureId,
|
||||
isBoolean: true,
|
||||
}) as ProductItem;
|
||||
|
||||
const booleanItems = (featureIds: readonly KnowledgePlatformFeatureId[]) =>
|
||||
featureIds.map(booleanItem);
|
||||
|
||||
const creditItems = () => [
|
||||
items.prepaid({
|
||||
featureId: knowledgePlatformFeatureIds.credits,
|
||||
billingUnits: 1_000,
|
||||
price: 100,
|
||||
}),
|
||||
items.consumable({
|
||||
featureId: knowledgePlatformFeatureIds.credits,
|
||||
billingUnits: 1,
|
||||
price: 0.1,
|
||||
}),
|
||||
];
|
||||
|
||||
export const buildKnowledgePlatformFeatures = ({
|
||||
ctx,
|
||||
}: {
|
||||
ctx: TestContext;
|
||||
}) => {
|
||||
const f = knowledgePlatformFeatureIds;
|
||||
const orgId = ctx.org.id;
|
||||
const env = ctx.env;
|
||||
const booleanFeatureIds = [
|
||||
...knowledgePlatformPlatformFeatureIds,
|
||||
...knowledgePlatformContractFeatureIds,
|
||||
];
|
||||
|
||||
return [
|
||||
constructMeteredFeature({
|
||||
featureId: f.activity_events,
|
||||
name: featureNames[f.activity_events],
|
||||
orgId,
|
||||
env,
|
||||
usageType: FeatureUsageType.Single,
|
||||
eventNames: ["activity_events"],
|
||||
}),
|
||||
constructCreditSystem({
|
||||
featureId: f.credits,
|
||||
orgId,
|
||||
env,
|
||||
schema: [{ metered_feature_id: f.activity_events, credit_cost: 1 }],
|
||||
}),
|
||||
constructMeteredFeature({
|
||||
featureId: f.member_slots,
|
||||
orgId,
|
||||
env,
|
||||
usageType: FeatureUsageType.Continuous,
|
||||
}),
|
||||
constructMeteredFeature({
|
||||
featureId: f.project_slots,
|
||||
orgId,
|
||||
env,
|
||||
usageType: FeatureUsageType.Continuous,
|
||||
}),
|
||||
constructMeteredFeature({
|
||||
featureId: f.workspaces,
|
||||
name: featureNames[f.workspaces],
|
||||
orgId,
|
||||
env,
|
||||
usageType: FeatureUsageType.Continuous,
|
||||
}),
|
||||
...booleanFeatureIds.map((featureId) =>
|
||||
constructBooleanFeature({
|
||||
featureId,
|
||||
name: featureNames[featureId],
|
||||
orgId,
|
||||
env,
|
||||
}),
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
export const ensureKnowledgePlatformFeatures = async ({
|
||||
ctx = defaultCtx,
|
||||
}: {
|
||||
ctx?: TestContext;
|
||||
} = {}) => {
|
||||
const desiredFeatures = buildKnowledgePlatformFeatures({ ctx });
|
||||
const existingFeatures = await FeatureService.list({
|
||||
db: ctx.db,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
});
|
||||
const existingById = new Map(
|
||||
existingFeatures.map((feature) => [feature.id, feature]),
|
||||
);
|
||||
|
||||
const featuresToInsert = desiredFeatures.filter(
|
||||
(feature) => !existingById.has(feature.id),
|
||||
);
|
||||
const featuresToUpdate = desiredFeatures.filter((feature) =>
|
||||
existingById.has(feature.id),
|
||||
);
|
||||
|
||||
if (featuresToInsert.length > 0) {
|
||||
await FeatureService.insert({
|
||||
db: ctx.db,
|
||||
data: featuresToInsert,
|
||||
logger: console,
|
||||
});
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
featuresToUpdate.map((feature) =>
|
||||
FeatureService.update({
|
||||
db: ctx.db,
|
||||
id: feature.id,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
updates: {
|
||||
name: feature.name,
|
||||
type: feature.type,
|
||||
config: feature.config,
|
||||
event_names: feature.event_names,
|
||||
model_markups: feature.model_markups,
|
||||
archived: false,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
ctx.features = await FeatureService.list({
|
||||
db: ctx.db,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
});
|
||||
|
||||
return ctx.features.filter((feature) =>
|
||||
desiredFeatures.some((desired) => desired.id === feature.id),
|
||||
);
|
||||
};
|
||||
|
||||
export const buildKnowledgePlatformProducts = () => {
|
||||
const f = knowledgePlatformFeatureIds;
|
||||
const p = knowledgePlatformPlanIds;
|
||||
const coreFeatures = [
|
||||
f.insight_reports,
|
||||
f.team_policies,
|
||||
f.private_spaces,
|
||||
f.export_center,
|
||||
f.automation_rules,
|
||||
f.platform_api,
|
||||
] as const;
|
||||
const expandedFeatures = [
|
||||
...coreFeatures,
|
||||
f.priority_queue,
|
||||
f.outbound_hooks,
|
||||
f.approval_chains,
|
||||
f.brand_controls,
|
||||
f.compliance_controls,
|
||||
f.revision_history,
|
||||
] as const;
|
||||
|
||||
const plans = {
|
||||
launch: products.base({
|
||||
id: p.launch,
|
||||
items: [
|
||||
items.monthlyPrice({ price: 300 }),
|
||||
...creditItems(),
|
||||
...booleanItems(coreFeatures),
|
||||
],
|
||||
}),
|
||||
scale: products.base({
|
||||
id: p.scale,
|
||||
items: [
|
||||
items.monthlyPrice({ price: 500 }),
|
||||
...creditItems(),
|
||||
...booleanItems(expandedFeatures),
|
||||
],
|
||||
}),
|
||||
scaleYearly: products.base({
|
||||
id: p.scaleYearly,
|
||||
items: [
|
||||
items.annualPrice({ price: 5_000 }),
|
||||
...creditItems(),
|
||||
...booleanItems(expandedFeatures),
|
||||
],
|
||||
}),
|
||||
trial: products.base({
|
||||
id: p.trial,
|
||||
items: [
|
||||
items.free({ featureId: f.credits, includedUsage: 1_000 }),
|
||||
...booleanItems([f.insight_reports, f.private_spaces, f.platform_api]),
|
||||
],
|
||||
}),
|
||||
enterprise: products.base({
|
||||
id: p.enterprise,
|
||||
items: [
|
||||
...creditItems(),
|
||||
items.free({ featureId: f.member_slots, includedUsage: 25 }),
|
||||
items.free({ featureId: f.project_slots, includedUsage: 100 }),
|
||||
...booleanItems(expandedFeatures),
|
||||
],
|
||||
}),
|
||||
automationPack: products.base({
|
||||
id: p.automationPack,
|
||||
isAddOn: true,
|
||||
items: [
|
||||
items.monthlyPrice({ price: 75 }),
|
||||
booleanItem(f.automation_rules),
|
||||
],
|
||||
}),
|
||||
securityPack: products.base({
|
||||
id: p.securityPack,
|
||||
isAddOn: true,
|
||||
items: [
|
||||
items.annualPrice({ price: 2_400 }),
|
||||
booleanItem(f.compliance_controls),
|
||||
],
|
||||
}),
|
||||
whiteLabelPack: products.base({
|
||||
id: p.whiteLabelPack,
|
||||
isAddOn: true,
|
||||
items: [
|
||||
items.annualPrice({ price: 3_000 }),
|
||||
booleanItem(f.brand_controls),
|
||||
],
|
||||
}),
|
||||
} satisfies Record<string, ProductV2>;
|
||||
|
||||
return {
|
||||
featureIds: knowledgePlatformFeatureIds,
|
||||
planIds: knowledgePlatformPlanIds,
|
||||
coreFeatures,
|
||||
expandedFeatures,
|
||||
plans,
|
||||
};
|
||||
};
|
||||
|
||||
export const initKnowledgePlatformScenario = async ({
|
||||
customerId = "agent-knowledge-platform",
|
||||
attachPlan = "trial",
|
||||
entityCount = 2,
|
||||
paymentMethod = "success",
|
||||
ctx = defaultCtx,
|
||||
}: {
|
||||
customerId?: string;
|
||||
attachPlan?: KnowledgePlatformPlanKey | null;
|
||||
entityCount?: number;
|
||||
paymentMethod?: "success" | "fail" | "authenticate" | "alipay";
|
||||
ctx?: TestContext;
|
||||
} = {}) => {
|
||||
await ensureKnowledgePlatformFeatures({ ctx });
|
||||
|
||||
const catalog = buildKnowledgePlatformProducts();
|
||||
const setup = [
|
||||
s.customer({ paymentMethod }),
|
||||
s.products({ list: Object.values(catalog.plans) }),
|
||||
...(entityCount > 0
|
||||
? [
|
||||
s.entities({
|
||||
count: entityCount,
|
||||
featureId: catalog.featureIds.workspaces,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const actions = attachPlan
|
||||
? [s.billing.attach({ productId: catalog.plans[attachPlan].id })]
|
||||
: [];
|
||||
const scenario = await initScenario({
|
||||
customerId,
|
||||
setup,
|
||||
actions,
|
||||
ctx,
|
||||
});
|
||||
|
||||
return {
|
||||
...scenario,
|
||||
...catalog,
|
||||
};
|
||||
};
|
||||
|
||||
export const seedKnowledgePlatformCustomers = async ({
|
||||
customerCount = 1_000,
|
||||
idPrefix = "kp-customer",
|
||||
entityCountForCustomer,
|
||||
productPrefix = "knowledge-platform",
|
||||
attachPlan = null,
|
||||
concurrency = 10,
|
||||
deleteExisting = true,
|
||||
ctx = defaultCtx,
|
||||
}: {
|
||||
customerCount?: number;
|
||||
idPrefix?: string;
|
||||
entityCountForCustomer?: (index: number) => 1 | 2;
|
||||
productPrefix?: string;
|
||||
attachPlan?: Extract<KnowledgePlatformPlanKey, "trial" | "enterprise"> | null;
|
||||
concurrency?: number;
|
||||
deleteExisting?: boolean;
|
||||
ctx?: TestContext;
|
||||
} = {}) => {
|
||||
await ensureKnowledgePlatformFeatures({ ctx });
|
||||
|
||||
const catalog = buildKnowledgePlatformProducts();
|
||||
await initScenario({
|
||||
setup: [
|
||||
s.products({
|
||||
list: Object.values(catalog.plans),
|
||||
prefix: productPrefix,
|
||||
createInStripe: false,
|
||||
}),
|
||||
],
|
||||
actions: [],
|
||||
ctx,
|
||||
});
|
||||
|
||||
const customers = Array.from({ length: customerCount }, (_, index) => {
|
||||
return {
|
||||
...buildRealisticCustomerSeed({
|
||||
index,
|
||||
idPrefix,
|
||||
entityFeatureId: knowledgePlatformFeatureIds.workspaces,
|
||||
entityCount: entityCountForCustomer?.(index),
|
||||
}),
|
||||
attachPlanId: attachPlan ? catalog.plans[attachPlan].id : null,
|
||||
};
|
||||
});
|
||||
|
||||
const seeded = await seedCustomersWithEntities({
|
||||
autumn: createScenarioAutumn({ ctx }),
|
||||
customers,
|
||||
concurrency,
|
||||
deleteExisting,
|
||||
});
|
||||
|
||||
return {
|
||||
...catalog,
|
||||
productPrefix,
|
||||
...seeded,
|
||||
};
|
||||
};
|
||||
|
||||
const getArgValue = (name: string) => {
|
||||
const prefix = `${name}=`;
|
||||
const inline = process.argv.find((arg) => arg.startsWith(prefix));
|
||||
if (inline) return inline.slice(prefix.length);
|
||||
|
||||
const index = process.argv.indexOf(name);
|
||||
return index === -1 ? undefined : process.argv[index + 1];
|
||||
};
|
||||
|
||||
const runKnowledgePlatformSeed = async () => {
|
||||
const customerCount = Number(getArgValue("--count") ?? "1000");
|
||||
const concurrency = Number(getArgValue("--concurrency") ?? "10");
|
||||
const attachPlan = getArgValue("--attach-plan") as
|
||||
| Extract<KnowledgePlatformPlanKey, "trial" | "enterprise">
|
||||
| undefined;
|
||||
const ctx = await createTestContext();
|
||||
|
||||
const result = await seedKnowledgePlatformCustomers({
|
||||
ctx,
|
||||
customerCount,
|
||||
concurrency,
|
||||
attachPlan: attachPlan ?? null,
|
||||
deleteExisting: !process.argv.includes("--keep-existing"),
|
||||
});
|
||||
|
||||
console.log("Knowledge platform seed complete", {
|
||||
customers: result.customerCount,
|
||||
entities: result.entityCount,
|
||||
productPrefix: result.productPrefix,
|
||||
attachPlan: attachPlan ?? null,
|
||||
});
|
||||
};
|
||||
|
||||
if (import.meta.main) {
|
||||
runKnowledgePlatformSeed()
|
||||
.catch((error) => {
|
||||
console.error("Knowledge platform seed failed:", error);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => {
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
25
server/tests/scenarios/seedUtils/concurrency.ts
Normal file
25
server/tests/scenarios/seedUtils/concurrency.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export const mapWithConcurrency = async <T, R>({
|
||||
list,
|
||||
concurrency,
|
||||
fn,
|
||||
}: {
|
||||
list: T[];
|
||||
concurrency: number;
|
||||
fn: (item: T, index: number) => Promise<R>;
|
||||
}) => {
|
||||
const results: R[] = [];
|
||||
let nextIndex = 0;
|
||||
const workerCount = Math.min(Math.max(concurrency, 1), list.length);
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: workerCount }, async () => {
|
||||
while (nextIndex < list.length) {
|
||||
const currentIndex = nextIndex;
|
||||
nextIndex += 1;
|
||||
results[currentIndex] = await fn(list[currentIndex], currentIndex);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return results;
|
||||
};
|
||||
23
server/tests/scenarios/seedUtils/customerSeedTypes.ts
Normal file
23
server/tests/scenarios/seedUtils/customerSeedTypes.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type {
|
||||
CreateCustomerInternalOptions,
|
||||
CreateEntityParams,
|
||||
} from "@autumn/shared";
|
||||
|
||||
export type SeedEntityInput = {
|
||||
id: string;
|
||||
name: string;
|
||||
featureId: string;
|
||||
customerData?: CreateEntityParams["customer_data"];
|
||||
};
|
||||
|
||||
export type SeedCustomerInput = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
entities: SeedEntityInput[];
|
||||
createInStripe?: boolean;
|
||||
internalOptions?: CreateCustomerInternalOptions;
|
||||
skipWebhooks?: boolean;
|
||||
attachPlanId?: string | null;
|
||||
};
|
||||
4
server/tests/scenarios/seedUtils/index.ts
Normal file
4
server/tests/scenarios/seedUtils/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./concurrency";
|
||||
export * from "./customerSeedTypes";
|
||||
export * from "./realisticData";
|
||||
export * from "./seedCustomers";
|
||||
185
server/tests/scenarios/seedUtils/realisticData.ts
Normal file
185
server/tests/scenarios/seedUtils/realisticData.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import type { SeedCustomerInput } from "./customerSeedTypes";
|
||||
|
||||
const industries = [
|
||||
"Analytics",
|
||||
"Architecture",
|
||||
"Biotech",
|
||||
"Commerce",
|
||||
"Compliance",
|
||||
"Education",
|
||||
"Energy",
|
||||
"Fintech",
|
||||
"Healthcare",
|
||||
"Logistics",
|
||||
"Media",
|
||||
"Robotics",
|
||||
] as const;
|
||||
|
||||
const companyPrefixes = [
|
||||
"Acme",
|
||||
"Aperture",
|
||||
"Atlas",
|
||||
"Bluebird",
|
||||
"Brightline",
|
||||
"Cedar",
|
||||
"Cobalt",
|
||||
"Evergreen",
|
||||
"Fable",
|
||||
"Harbor",
|
||||
"Juniper",
|
||||
"Lattice",
|
||||
"Northstar",
|
||||
"Oakline",
|
||||
"Prairie",
|
||||
"Redwood",
|
||||
"Signal",
|
||||
"Summit",
|
||||
"Terra",
|
||||
"Waypoint",
|
||||
] as const;
|
||||
|
||||
const companySuffixes = [
|
||||
"Analytics",
|
||||
"Cloud",
|
||||
"Collective",
|
||||
"Data",
|
||||
"Dynamics",
|
||||
"Group",
|
||||
"Health",
|
||||
"Labs",
|
||||
"Logistics",
|
||||
"Media",
|
||||
"Research",
|
||||
"Systems",
|
||||
"Works",
|
||||
] as const;
|
||||
|
||||
const ownerFirstNames = [
|
||||
"Alex",
|
||||
"Amara",
|
||||
"Ben",
|
||||
"Camille",
|
||||
"Daniel",
|
||||
"Elena",
|
||||
"Fatima",
|
||||
"Grace",
|
||||
"Hannah",
|
||||
"Ivan",
|
||||
"Jonah",
|
||||
"Leah",
|
||||
"Maya",
|
||||
"Nadia",
|
||||
"Owen",
|
||||
"Priya",
|
||||
"Rafael",
|
||||
"Sofia",
|
||||
"Theo",
|
||||
"Vivian",
|
||||
] as const;
|
||||
|
||||
const ownerLastNames = [
|
||||
"Brooks",
|
||||
"Chen",
|
||||
"Diaz",
|
||||
"Evans",
|
||||
"Foster",
|
||||
"Grant",
|
||||
"Hughes",
|
||||
"Iyer",
|
||||
"Kim",
|
||||
"Lawson",
|
||||
"Morgan",
|
||||
"Patel",
|
||||
"Reed",
|
||||
"Singh",
|
||||
"Stone",
|
||||
"Turner",
|
||||
"Wong",
|
||||
"Young",
|
||||
] as const;
|
||||
|
||||
const regions = [
|
||||
"AMER",
|
||||
"APAC",
|
||||
"Benelux",
|
||||
"DACH",
|
||||
"EMEA",
|
||||
"LATAM",
|
||||
"Northern Europe",
|
||||
"Southern Europe",
|
||||
] as const;
|
||||
|
||||
const lifecycleStages = [
|
||||
"evaluation",
|
||||
"implementation",
|
||||
"launched",
|
||||
"expansion",
|
||||
"renewal",
|
||||
] as const;
|
||||
|
||||
const workspaceNames = [
|
||||
"Billing Ops",
|
||||
"Customer Success",
|
||||
"Developer Platform",
|
||||
"Enterprise Rollout",
|
||||
"Finance Systems",
|
||||
"Growth Experiments",
|
||||
"Knowledge Base",
|
||||
"Operations",
|
||||
"Partner Portal",
|
||||
"Research",
|
||||
"Security Review",
|
||||
"Support Desk",
|
||||
] as const;
|
||||
|
||||
export type RealisticCustomerSeed = SeedCustomerInput & {
|
||||
entityCount: number;
|
||||
};
|
||||
|
||||
const pick = <T>(list: readonly T[], index: number) =>
|
||||
list[index % list.length];
|
||||
|
||||
const slugify = (value: string) =>
|
||||
value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
|
||||
export const buildRealisticCustomerSeed = ({
|
||||
index,
|
||||
idPrefix,
|
||||
entityFeatureId,
|
||||
entityCount = index % 3 === 0 ? 2 : 1,
|
||||
}: {
|
||||
index: number;
|
||||
idPrefix: string;
|
||||
entityFeatureId: string;
|
||||
entityCount?: 1 | 2;
|
||||
}): RealisticCustomerSeed => {
|
||||
const name = `${pick(companyPrefixes, index)} ${pick(companySuffixes, Math.floor(index / companyPrefixes.length))}`;
|
||||
const id = `${idPrefix}-${String(index + 1).padStart(4, "0")}`;
|
||||
const domain = `${slugify(name)}.example`;
|
||||
const entities = Array.from({ length: entityCount }, (_, entityIndex) => ({
|
||||
id: `${id}-workspace-${entityIndex + 1}`,
|
||||
name: `${pick(workspaceNames, index + entityIndex)} Workspace`,
|
||||
featureId: entityFeatureId,
|
||||
}));
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
email: `billing+${id}@${domain}`,
|
||||
entityCount,
|
||||
entities,
|
||||
createInStripe: false,
|
||||
internalOptions: { disable_defaults: true },
|
||||
skipWebhooks: true,
|
||||
metadata: {
|
||||
account_owner: `${pick(ownerFirstNames, index)} ${pick(ownerLastNames, index)}`,
|
||||
company_size: 25 + ((index * 37) % 975),
|
||||
industry: pick(industries, index),
|
||||
lifecycle_stage: pick(lifecycleStages, index),
|
||||
region: pick(regions, index),
|
||||
},
|
||||
};
|
||||
};
|
||||
93
server/tests/scenarios/seedUtils/seedCustomers.ts
Normal file
93
server/tests/scenarios/seedUtils/seedCustomers.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
ApiVersion,
|
||||
type CreateEntityParams,
|
||||
type LegacyVersion,
|
||||
} from "@autumn/shared";
|
||||
import type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli";
|
||||
import { mapWithConcurrency } from "./concurrency";
|
||||
import type { SeedCustomerInput } from "./customerSeedTypes";
|
||||
|
||||
export const createScenarioAutumn = ({
|
||||
ctx,
|
||||
version = ApiVersion.V1_2,
|
||||
}: {
|
||||
ctx: TestContext;
|
||||
version?: string | LegacyVersion;
|
||||
}) =>
|
||||
new AutumnInt({
|
||||
version,
|
||||
secretKey: ctx.orgSecretKey,
|
||||
});
|
||||
|
||||
export const seedCustomersWithEntities = async <
|
||||
TCustomer extends SeedCustomerInput,
|
||||
>({
|
||||
autumn,
|
||||
customers,
|
||||
concurrency = 10,
|
||||
deleteExisting = true,
|
||||
}: {
|
||||
autumn: AutumnInt;
|
||||
customers: TCustomer[];
|
||||
concurrency?: number;
|
||||
deleteExisting?: boolean;
|
||||
}) => {
|
||||
const seededCustomers = await mapWithConcurrency({
|
||||
list: customers,
|
||||
concurrency,
|
||||
fn: async (customer) => {
|
||||
if (deleteExisting) {
|
||||
try {
|
||||
await autumn.customers.delete(customer.id);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
await autumn.customers.create({
|
||||
id: customer.id,
|
||||
name: customer.name,
|
||||
email: customer.email,
|
||||
metadata: customer.metadata,
|
||||
create_in_stripe: customer.createInStripe ?? false,
|
||||
internalOptions: customer.internalOptions ?? {
|
||||
disable_defaults: true,
|
||||
},
|
||||
skipWebhooks: customer.skipWebhooks ?? true,
|
||||
});
|
||||
|
||||
if (customer.entities.length > 0) {
|
||||
const entityPayloads = customer.entities.map(
|
||||
(entity): CreateEntityParams => ({
|
||||
id: entity.id,
|
||||
name: entity.name,
|
||||
feature_id: entity.featureId,
|
||||
customer_data: entity.customerData,
|
||||
}),
|
||||
);
|
||||
|
||||
await autumn.entities.create(customer.id, entityPayloads);
|
||||
}
|
||||
|
||||
if (customer.attachPlanId) {
|
||||
await autumn.billing.attach(
|
||||
{
|
||||
customer_id: customer.id,
|
||||
product_id: customer.attachPlanId,
|
||||
},
|
||||
{ skipWebhooks: customer.skipWebhooks ?? true, timeout: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
return customer;
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
customerCount: seededCustomers.length,
|
||||
entityCount: seededCustomers.reduce(
|
||||
(total, customer) => total + customer.entities.length,
|
||||
0,
|
||||
),
|
||||
customers: seededCustomers,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user