Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ export type Automation = AutomationSummary & {
trigger_tier_scope: 'selected_paid';
trigger_tier_ids: string[];
}
| {
trigger_type: 'paid_subscription_changed';
subscription_transition: Extract<
AddAutomationData,
{ trigger_type: 'paid_subscription_changed' }
>['subscription_transition'];
}
);

export type EditAutomationData = {
Expand Down
37 changes: 34 additions & 3 deletions ghost/core/server/services/automations/automations-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,42 @@ const selectedPaidTriggerShape = {
}),
};

export const addAutomationDataSchema = z.discriminatedUnion('trigger_tier_scope', [
z.strictObject({ ...automationShape, ...defaultTriggerShape }),
const transitionTierIdsSchema = z
.array(objectIdSchema)
.min(1)
.refine((ids) => new Set(ids).size === ids.length, {
message: 'Duplicate tier IDs are not allowed.',
});

const subscriptionTransitionSchema = z
.strictObject({
from: z.union([z.literal('any_paid'), transitionTierIdsSchema]),
to: z.union([z.literal('downgraded_to_free'), transitionTierIdsSchema]),
})
.refine(
({ from, to }) =>
!Array.isArray(from) || !Array.isArray(to) || !from.some((id) => to.includes(id)),
{ message: 'From and To tiers must not overlap.', path: ['to'] },
);

export const addAutomationDataSchema = z.discriminatedUnion('trigger_type', [
z.discriminatedUnion('trigger_tier_scope', [
z.strictObject({
...automationShape,
...defaultTriggerShape,
// Legacy frontends omit trigger_type; membership_started remains the default.
trigger_type: z.literal('membership_started').optional(),
}),
z.strictObject({
...automationShape,
...selectedPaidTriggerShape,
trigger_type: z.literal('membership_started').optional(),
}),
]),
z.strictObject({
...automationShape,
...selectedPaidTriggerShape,
trigger_type: z.literal('paid_subscription_changed'),
subscription_transition: subscriptionTransitionSchema,
}),
]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ type AutomationSummaryRow = {
};

type AutomationRow = AutomationSummaryRow & {
trigger_type: 'membership_started';
trigger_type: 'membership_started' | 'paid_subscription_changed';
trigger_tier_scope: AutomationTriggerTierScope | null;
};

Expand Down Expand Up @@ -362,19 +362,60 @@ export function createDatabaseAutomationsRepository({
if (data.status === 'active') {
await limitService.errorIfWouldGoOverLimit('limitAutomations', { transacting: trx });
}
const tierIds = data.trigger_tier_ids ?? [];
if (tierIds.length > 0) {
// Lock the products so they aren't archived or deleted while we're adding.
const trigger = (() => {
switch (data.trigger_type) {
case undefined:
case 'membership_started':
return {
tierIds: data.trigger_tier_ids ?? [],
tierFilter: { type: 'paid', active: true },
invalidTiersMessage: 'Trigger tiers must all be active paid tiers.',
invalidTiersProperty: 'trigger_tier_ids',
automationFields: {
trigger_type: 'membership_started' as const,
trigger_tier_scope: data.trigger_tier_scope ?? null,
},
};
case 'paid_subscription_changed': {
const transition = data.subscription_transition;
return {
tierIds: [
...(Array.isArray(transition.from) ? transition.from : []),
...(Array.isArray(transition.to) ? transition.to : []),
],
tierFilter: { type: 'paid' },
invalidTiersMessage: 'Transition tiers must all be paid tiers.',
invalidTiersProperty: 'subscription_transition',
automationFields: {
trigger_type: 'paid_subscription_changed' as const,
trigger_tier_scope: null,
trigger_from_tier_scope:
transition.from === 'any_paid' ? 'all_paid' : 'selected_paid',
trigger_to_tier_scope:
transition.to === 'downgraded_to_free' ? 'free' : 'selected_paid',
},
};
}
default: {
const _exhaustive: never = data;
throw new errors.InternalServerError({
message: `Unexpected automation trigger: ${_exhaustive}`,
});
}
}
})();
if (trigger.tierIds.length > 0) {
// Lock products so their eligibility cannot change while creating the automation.
const tiers = await trx('products')
.select('id')
.whereIn('id', tierIds)
.where({ type: 'paid', active: true })
.whereIn('id', trigger.tierIds)
.where(trigger.tierFilter)
.orderBy('id')
.forUpdate();
if (tiers.length !== tierIds.length) {
if (tiers.length !== trigger.tierIds.length) {
throw new errors.ValidationError({
message: 'Trigger tiers must all be active paid tiers.',
property: 'trigger_tier_ids',
message: trigger.invalidTiersMessage,
property: trigger.invalidTiersProperty,
});
}
}
Expand All @@ -386,23 +427,71 @@ export function createDatabaseAutomationsRepository({
name: data.name,
description: data.description,
status: data.status,
trigger_type: 'membership_started',
trigger_tier_scope: data.trigger_tier_scope ?? null,
...trigger.automationFields,
created_at: now,
updated_at: now,
};

await trx('automations').insert(automation);

if (tierIds.length > 0) {
await trx('automation_trigger_tiers').insert(
tierIds.map((productId) => ({ automation_id: automation.id, product_id: productId })),
);
switch (data.trigger_type) {
case undefined:
case 'membership_started':
if (trigger.tierIds.length > 0) {
await trx('automation_trigger_tiers').insert(
trigger.tierIds.map((productId) => ({
automation_id: automation.id,
product_id: productId,
})),
);
}
break;
case 'paid_subscription_changed':
if (trigger.tierIds.length > 0) {
await trx('automation_trigger_transition_tiers').insert(
(['from', 'to'] as const).flatMap((direction) => {
const ids = data.subscription_transition[direction];
return Array.isArray(ids)
? ids.map((productId) => ({
automation_id: automation.id,
direction,
product_id: productId,
}))
: [];
}),
);
}
break;
default: {
const _exhaustive: never = data;
throw new errors.InternalServerError({
message: `Unexpected automation trigger: ${_exhaustive}`,
});
}
}

await replaceAutomationGraph(repository2, trx, automation.id, data.actions, data.edges);

return await buildAutomation(repository2, trx, automation);
const result = await buildAutomation(repository2, trx, automation);
switch (data.trigger_type) {
case undefined:
case 'membership_started':
return result;
case 'paid_subscription_changed':
return {
...buildAutomationSummary(automation),
trigger_type: 'paid_subscription_changed' as const,
subscription_transition: data.subscription_transition,
actions: result.actions,
edges: result.edges,
};
default: {
const _exhaustive: never = data;
throw new errors.InternalServerError({
message: `Unexpected automation trigger: ${_exhaustive}`,
});
}
}
});
},

Expand Down
105 changes: 105 additions & 0 deletions ghost/test/e2e-api/admin/automations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ type AutomationAction = {
};

type Automation = {
trigger_type?: string;
subscription_transition?: { from: string | string[]; to: string | string[] };
id: string;
updated_at: string;
name: string;
Expand Down Expand Up @@ -338,6 +340,109 @@ describe('Automations API', function () {
);
});

const { trigger_tier_scope: _legacyScope, ...transitionPayload } = payload;

for (const fromScope of ['any_paid', 'selected_paid'] as const) {
for (const toScope of ['downgraded_to_free', 'selected_paid'] as const) {
it(`creates subscription transition ${fromScope} to ${toScope}`, async function () {
// Archived paid tiers remain valid transition endpoints.
await models.Base.knex('products').whereIn('id', tierIds).update({ active: false });
const subscription_transition = {
from: fromScope === 'any_paid' ? fromScope : [tierIds[0]],
to: toScope === 'downgraded_to_free' ? toScope : [tierIds[1]],
};
const { body } = await agent
.post('automations')
.body({
automations: [
{
...transitionPayload,
trigger_type: 'paid_subscription_changed',
subscription_transition,
},
],
})
.expectStatus(201);
const created = body.automations[0];
assert.equal(created.trigger_type, 'paid_subscription_changed');
assert.deepEqual(created.subscription_transition, subscription_transition);
assert.equal('trigger_tier_scope' in created, false);
assert.equal('trigger_tier_ids' in created, false);
assert.equal(created.name, payload.name);
assert.deepEqual(created.edges, payload.edges);
const row = await models.Base.knex('automations').where({ id: created.id }).first();
assert.equal(row.trigger_type, 'paid_subscription_changed');
assert.equal(row.trigger_tier_scope, null);
assert.equal(
row.trigger_from_tier_scope,
fromScope === 'any_paid' ? 'all_paid' : 'selected_paid',
);
assert.equal(
row.trigger_to_tier_scope,
toScope === 'downgraded_to_free' ? 'free' : 'selected_paid',
);
assert.deepEqual(
await models.Base.knex('automation_trigger_tiers').where({ automation_id: created.id }),
[],
);
const expected = [
...(Array.isArray(subscription_transition.from)
? [{ direction: 'from', product_id: tierIds[0] }]
: []),
...(Array.isArray(subscription_transition.to)
? [{ direction: 'to', product_id: tierIds[1] }]
: []),
];
assert.deepEqual(
await models.Base.knex('automation_trigger_transition_tiers')
.where({ automation_id: created.id })
.select('direction', 'product_id')
.orderBy('direction'),
expected,
);
});
}
}

it.each(['from', 'to'] as const)(
'rejects missing or free %s tiers atomically',
async function (direction) {
for (const invalidTier of ['missing', 'free']) {
if (invalidTier === 'missing') {
await models.Base.knex('products').where({ id: tierIds[1] }).delete();
} else {
await models.Base.knex('products').insert({
id: tierIds[1],
name: tierIds[1],
slug: tierIds[1],
type: 'free',
created_at: new Date(),
});
}
const { body } = await agent
.post('automations')
.body({
automations: [
{
...transitionPayload,
trigger_type: 'paid_subscription_changed',
subscription_transition: {
from: direction === 'from' ? [tierIds[1]] : 'any_paid',
to: direction === 'to' ? [tierIds[1]] : 'downgraded_to_free',
},
},
],
})
.expectStatus(422);
assert.equal(body.errors[0].property, 'subscription_transition');
assert.equal(
await models.Base.knex('automations').where({ name: payload.name }).first(),
undefined,
);
}
},
);

it('trims name and description before saving', async function () {
const { body } = await agent
.post('automations')
Expand Down
Loading
Loading