Skip to content
Open
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
2 changes: 2 additions & 0 deletions docs/openapi/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,8 @@ paths:
$ref: './llmo-api.yaml#/llmo-onboard-site'
/sites/{siteId}/llmo/offboard:
$ref: './llmo-api.yaml#/llmo-offboard'
/sites/{siteId}/prompt-suggestion-schedules:
$ref: './llmo-api.yaml#/llmo-prompt-suggestion-schedules'
/sites/{siteId}/llmo/opportunities-reviewed:
$ref: './llmo-api.yaml#/llmo-opportunities-reviewed'
/sites/{siteId}/llmo/brand-claims:
Expand Down
44 changes: 44 additions & 0 deletions docs/openapi/llmo-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1347,6 +1347,50 @@ llmo-offboard:
security:
- api_key: [ ]

llmo-prompt-suggestion-schedules:
parameters:
- $ref: './parameters.yaml#/siteId'
post:
tags:
- llmo
summary: Provision recurring prompt-suggestion schedules for a site
description: |
(Re)provisions the recurring DRS prompt-suggestion pipelines for a site,
keyed off the site's CURRENT LLMO tier (re-derived server-side — the caller
cannot supply a tier). Only PAID sites get recurring schedules; a non-paying
site is a no-op (skipped), because the trial one-shot run is an
onboarding-only behavior. The underlying schedule creation is idempotent, so
repeat calls are safe and already-existing schedules count as success.

Admin-or-S2S: reachable by admin `x-api-key` callers and by S2S consumers
holding the `promptSuggestionSchedule:write` capability (used by the
fulfillment-worker after a Commerce trial→paid flip and by a backfill
reconciler — both follow-ups).
operationId: createPromptSuggestionSchedules
responses:
'200':
description: >-
Provisioning completed. For a paying site the per-pipeline results are
returned; for a non-paying site the response indicates it was skipped.
`allSucceeded` reflects whether every pipeline succeeded (already-existing
schedules count as success); inspect `results` for per-pipeline status.
content:
application/json:
schema:
$ref: './schemas.yaml#/PromptSuggestionSchedulesResult'
'400':
$ref: './responses.yaml#/400'
'401':
$ref: './responses.yaml#/401'
'403':
$ref: './responses.yaml#/403'
'404':
$ref: './responses.yaml#/404'
'500':
$ref: './responses.yaml#/500'
security:
- api_key: [ ]

llmo-questions:
parameters:
- $ref: './parameters.yaml#/siteId'
Expand Down
65 changes: 65 additions & 0 deletions docs/openapi/schemas.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4714,6 +4714,71 @@ LlmoQuestion:
example: ['product', 'pricing', 'features']
additionalProperties: true

PromptSuggestionScheduleResult:
type: object
description: Outcome of provisioning a single prompt-suggestion pipeline for a site.
properties:
providerId:
type: string
description: The DRS provider id of the prompt-suggestion pipeline.
example: 'prompt_generation_semrush'
status:
type: string
enum: ['created', 'already-existed', 'submitted', 'failed']
description: >-
Per-pipeline outcome. `created` — a new recurring schedule was registered;
`already-existed` — an idempotent no-op (treated as success); `submitted` —
a one-shot run was submitted (trial path); `failed` — provisioning failed
(see `error`).
example: 'created'
error:
type: string
description: Failure message, present only when `status` is `failed`.
example: 'DRS POST /schedules failed: 500'
required:
- providerId
- status

PromptSuggestionSchedulesResult:
type: object
description: >-
Result of (re)provisioning a site's recurring prompt-suggestion schedules.
When the site is not on the paying tier, `skipped` is true and no schedules
are created.
properties:
siteId:
type: string
format: uuid
description: The site the schedules were provisioned for.
example: '48656b02-62cb-46c0-b3fe-4a1a0f0c3b2f'
isPaying:
type: boolean
description: Whether the site is currently on the paying LLMO tier (re-derived server-side).
example: true
skipped:
type: boolean
description: True when no work was done because the site is not on the paying tier.
example: false
reason:
type: string
description: Why provisioning was skipped, present only when `skipped` is true.
example: 'not-paying'
allSucceeded:
type: boolean
description: True iff every pipeline succeeded (already-existing schedules count as success).
example: true
results:
type: array
description: Per-pipeline provisioning outcomes (empty when skipped).
items:
$ref: '#/PromptSuggestionScheduleResult'
required:
- siteId
- isPaying
- skipped
- allSucceeded
- results

LlmoQuestions:
type: object
description: LLMO questions configuration containing both human and AI questions
Expand Down
58 changes: 58 additions & 0 deletions src/controllers/entitlements.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,55 @@ import {

import { Entitlement as EntitlementModel } from '@adobe/spacecat-shared-data-access';
import TierClient from '@adobe/spacecat-shared-tier-client';
import DrsClient from '@adobe/spacecat-shared-drs-client';

import { EntitlementDto } from '../dto/entitlement.js';
import { SiteEnrollmentDto } from '../dto/site-enrollment.js';
import AccessControlUtil from '../support/access-control-util.js';
import { ensurePromptSuggestionSchedules } from '../support/prompt-suggestion-schedules.js';

const VALID_PRODUCT_CODES = new Set(Object.values(EntitlementModel.PRODUCT_CODES));
const FREE_TRIAL_TIER = EntitlementModel.TIERS.FREE_TRIAL;
const PAID_TIER = EntitlementModel.TIERS.PAID;
const LLMO_PRODUCT_CODE = EntitlementModel.PRODUCT_CODES.LLMO;

/**
* Best-effort reaction to a trial→paid LLMO transition: (re)provisions the site's
* recurring DRS prompt-suggestion schedules. Never throws — the entitlement
* operation must succeed regardless of this side-effect (mirrors the best-effort
* schedule registration on the onboarding path). The dedicated endpoint
* (`POST /sites/:siteId/prompt-suggestion-schedules`) is the reusable equivalent;
* this inlines the same shared helper so an admin tier flip is not silently
* missed while the fulfillment-worker call + reconciler are still follow-ups.
*
* @param {object} context - Request context (for DrsClient + log).
* @param {string} siteId - Site UUID.
* @param {object} log - Logger.
* @returns {Promise<void>}
*/
async function reactToLlmoTrialToPaid(context, siteId, log) {
try {
const drsClient = DrsClient.createFrom(context);
if (!drsClient.isConfigured()) {
log.debug(`[prompt-suggestion-schedules] DRS not configured, skipping trial→paid reaction for site ${siteId}`);
return;
}
const { results, allSucceeded } = await ensurePromptSuggestionSchedules({
drsClient,
siteId,
isPaying: true,
log,
});
const summary = results.map((r) => `${r.providerId}:${r.status}`).join(',');
if (allSucceeded) {
log.info(`[prompt-suggestion-schedules] trial→paid provisioned recurring schedules site_id=${siteId} results=${summary}`);
} else {
log.error(`[prompt-suggestion-schedules] trial→paid: one or more pipelines failed site_id=${siteId} results=${summary}`);
}
} catch (e) {
log.error(`[prompt-suggestion-schedules] trial→paid reaction failed for site ${siteId}: ${e.message}`);
}
}

/**
* Entitlements controller. Provides methods to read entitlements by organization.
Expand Down Expand Up @@ -185,7 +227,23 @@ function EntitlementsController(ctx) {
site,
productCode,
);
// Read the prior tier BEFORE the create so we can detect a trial→paid
// transition without changing the shared createEntitlement return shape
// (mirrors resolveProvisioningTier in support/tier-provisioning.js).
const existing = await tierClient.checkValidEntitlement();
const prevTier = existing.entitlement?.getTier?.() ?? null;

const { entitlement, siteEnrollment } = await tierClient.createEntitlement(tier);

// LLMO trial→paid: (re)provision recurring prompt-suggestion schedules.
// Best-effort — never fails the entitlement op.
const resultTier = entitlement?.getTier?.();
if (productCode === LLMO_PRODUCT_CODE
&& prevTier !== PAID_TIER
&& resultTier === PAID_TIER) {
await reactToLlmoTrialToPaid(context, siteId, context.log);
}

return created({
entitlement: EntitlementDto.toJSON(entitlement),
siteEnrollment: SiteEnrollmentDto.toJSON(siteEnrollment),
Expand Down
Loading
Loading