Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions src/commands/audience/archive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { Args, Flags, ux } from "@oclif/core";

import BaseCommand from "@/lib/base-command";
import { ApiError } from "@/lib/helpers/error";
import * as CustomFlags from "@/lib/helpers/flag";
import { formatErrorRespMessage, isSuccessResp } from "@/lib/helpers/request";
import { promptToConfirm, spinner } from "@/lib/helpers/ux";

export default class AudienceArchive extends BaseCommand<
typeof AudienceArchive
> {
static summary = "Archive an audience (affects ALL environments).";

static description = `
WARNING: Archiving an audience affects ALL environments and cannot be undone.
Use this command with caution.
`;

static flags = {
environment: Flags.string({
required: true,
summary: "The environment to use.",
}),
branch: CustomFlags.branch,
force: Flags.boolean({
summary: "Skip confirmation prompt.",
}),
};

static args = {
audienceKey: Args.string({
required: true,
description: "The key of the audience to archive.",
}),
};

async run(): Promise<void> {
const { audienceKey } = this.props.args;
const { force } = this.props.flags;

// Confirm before archiving since this affects all environments
if (!force) {
const confirmed = await promptToConfirm(
`WARNING: Archiving audience \`${audienceKey}\` will affect ALL environments.\n` +
`This action cannot be undone. Continue?`,
);
if (!confirmed) {
this.log("Archive cancelled.");
return;
}
}

spinner.start(`‣ Archiving audience \`${audienceKey}\``);

const resp = await this.apiV1.archiveAudience(this.props);

spinner.stop();

if (!isSuccessResp(resp)) {
const message = formatErrorRespMessage(resp);
ux.error(new ApiError(message));
}

this.log(
`‣ Successfully archived audience \`${audienceKey}\` across all environments.`,
);
}
}
222 changes: 222 additions & 0 deletions src/commands/audience/new.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import * as path from "node:path";

import { Flags } from "@oclif/core";
import { prompt } from "enquirer";

import BaseCommand from "@/lib/base-command";
import { KnockEnv } from "@/lib/helpers/const";
import * as CustomFlags from "@/lib/helpers/flag";
import { resolveResourceDir } from "@/lib/helpers/project-config";
import { slugify } from "@/lib/helpers/string";
import { promptToConfirm, spinner } from "@/lib/helpers/ux";
import * as Audience from "@/lib/marshal/audience";
import { AudienceType } from "@/lib/marshal/audience";
import {
AudienceDirContext,
ensureResourceDirForTarget,
ResourceTarget,
} from "@/lib/run-context";

import AudiencePush from "./push";

const AUDIENCE_TYPE_CHOICES = [
{ name: AudienceType.Static, message: "Static (manual membership)" },
{ name: AudienceType.Dynamic, message: "Dynamic (rule-based membership)" },
] as const;

export default class AudienceNew extends BaseCommand<typeof AudienceNew> {
static summary = "Create a new audience with a minimal configuration.";

static flags = {
name: Flags.string({
summary: "The name of the audience",
char: "n",
}),
key: Flags.string({
summary: "The key of the audience",
char: "k",
}),
type: Flags.string({
summary: "The type of the audience (static, dynamic).",
char: "t",
options: [AudienceType.Static, AudienceType.Dynamic],
}),
description: Flags.string({
summary: "The description of the audience",
char: "d",
}),
environment: Flags.string({
summary:
"The environment to create the audience in. Defaults to development.",
default: KnockEnv.Development,
}),
branch: CustomFlags.branch,
force: Flags.boolean({
summary:
"Force the creation of the audience directory without confirmation.",
}),
push: Flags.boolean({
summary: "Whether or not to push the audience to Knock after creation.",
default: false,
char: "p",
}),
};

static args = {};

async run(): Promise<void> {
const { flags } = this.props;
const { resourceDir } = this.runContext;

// 1. Ensure we aren't in any existing resource directory already.
if (resourceDir) {
return this.error(
`Cannot create a new audience inside an existing ${resourceDir.type} directory`,
);
}

// 2. Prompt for name and key if not provided
let name = flags.name;
let key = flags.key;

if (!name) {
const nameResponse = await prompt<{ name: string }>({
type: "input",
name: "name",
message: "Audience name",
validate: (value: string) => {
if (!value || value.trim().length === 0) {
return "Audience name is required";
}

return true;
},
});
name = nameResponse.name;
}

if (!key) {
const keyResponse = await prompt<{ key: string }>({
type: "input",
name: "key",
message: "Audience key (immutable slug)",
initial: slugify(name),
validate: (value: string) => {
if (!value || value.trim().length === 0) {
return "Audience key is required";
}

const keyError = Audience.validateAudienceKey(value);
if (keyError) {
return `Invalid audience key: ${keyError}`;
}

return true;
},
});
key = keyResponse.key;
}

// Validate the audience key
const audienceKeyError = Audience.validateAudienceKey(key);
if (audienceKeyError) {
return this.error(
`Invalid audience key \`${key}\` (${audienceKeyError})`,
);
}

const audienceDirCtx = await this.getAudienceDirContext(key);

const promptMessage = audienceDirCtx.exists
? `Found \`${audienceDirCtx.key}\` at ${audienceDirCtx.abspath}, overwrite?`
: `Create a new audience directory \`${audienceDirCtx.key}\` at ${audienceDirCtx.abspath}?`;

// Check if the audience directory already exists, and prompt to confirm if not.
const input = flags.force || (await promptToConfirm(promptMessage));
if (!input) return;

// 3. Prompt for type if not provided
let audienceType: AudienceType;

if (flags.type) {
audienceType = flags.type as AudienceType;
} else {
const typeResponse = await prompt<{ type: string }>({
type: "select",
name: "type",
message: "Select audience type",
choices: AUDIENCE_TYPE_CHOICES.map((choice) => ({
name: choice.name,
message: choice.message,
})),
});

audienceType = typeResponse.type as AudienceType;
}

// Generate the audience directory with scaffolded content
await Audience.generateAudienceDir(audienceDirCtx, {
name,
type: audienceType,
description: flags.description,
});

if (flags.push) {
spinner.start("‣ Pushing audience to Knock");

try {
await AudiencePush.run([key]);
} catch (error) {
this.error(`Failed to push audience to Knock: ${error}`);
} finally {
spinner.stop();
}
}

this.log(`‣ Successfully created audience \`${key}\``);
}

async getAudienceDirContext(
audienceKey?: string,
): Promise<AudienceDirContext> {
const { resourceDir, cwd: runCwd } = this.runContext;

// Inside an existing resource dir, use it if valid for the target audience.
if (resourceDir) {
const target: ResourceTarget = {
commandId: BaseCommand.id,
type: "audience",
key: audienceKey,
};

return ensureResourceDirForTarget(
resourceDir,
target,
) as AudienceDirContext;
}

// Default to knock project config first if present, otherwise cwd.
const dirCtx = await resolveResourceDir(
this.projectConfig,
"audience",
runCwd,
);

// Not inside any existing audience directory, which means either create a
// new audience directory in the cwd, or update it if there is one already.
if (audienceKey) {
const dirPath = path.resolve(dirCtx.abspath, audienceKey);
const exists = await Audience.isAudienceDir(dirPath);

return {
type: "audience",
key: audienceKey,
abspath: dirPath,
exists,
};
}

// Not in any audience directory, nor an audience key arg was given so error.
return this.error("Missing 1 required arg:\naudienceKey");
}
}
70 changes: 70 additions & 0 deletions src/lib/marshal/audience/generator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { AudienceDirContext } from "@/lib/run-context";

import { AUDIENCE_JSON, AudienceDirBundle } from "./processor.isomorphic";
import { AudienceType } from "./types";
import { writeAudienceDirFromBundle } from "./writer";

/*
* Attributes for creating a new audience from scratch.
*/
type NewAudienceAttrs = {
name: string;
type: AudienceType;
description?: string;
};

/*
* Returns the default scaffolded segments for a dynamic audience.
*/
const defaultSegmentsForDynamic = () => [
{
conditions: [
{
property: "recipient.plan",
operator: "equal_to",
argument: "premium",
},
],
},
];

/*
* Scaffolds a new audience directory bundle with default content.
*/
const scaffoldAudienceDirBundle = (
attrs: NewAudienceAttrs,
): AudienceDirBundle => {
const audienceJson: Record<string, unknown> = {
name: attrs.name,
type: attrs.type,
};

if (attrs.description) {
audienceJson.description = attrs.description;
}

// For dynamic audiences, include example segments
if (attrs.type === AudienceType.Dynamic) {
audienceJson.segments = defaultSegmentsForDynamic();
}

return {
[AUDIENCE_JSON]: audienceJson,
};
};

/*
* Generates a new audience directory with a scaffolded audience.json file.
* Assumes the given audience directory context is valid and correct.
*/
export const generateAudienceDir = async (
audienceDirCtx: AudienceDirContext,
attrs: NewAudienceAttrs,
): Promise<void> => {
const bundle = scaffoldAudienceDirBundle(attrs);

return writeAudienceDirFromBundle(audienceDirCtx, bundle);
};

// Exported for tests.
export { scaffoldAudienceDirBundle };
1 change: 1 addition & 0 deletions src/lib/marshal/audience/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./generator";
export * from "./helpers";
export * from "./processor.isomorphic";
export * from "./reader";
Expand Down
Loading
Loading