Skip to content

Commit 1c86a8c

Browse files
author
Radhika Gupta
committed
feat: auto-create product entitlement and site enrollment via x-product header
1 parent fb0d176 commit 1c86a8c

7 files changed

Lines changed: 353 additions & 20 deletions

File tree

docs/openapi/organizations-api.yaml

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,35 @@ organizations:
44
- organization
55
summary: Create a new organization
66
description: |
7-
This endpoint is useful for creating a new organization.
7+
Creates a new organization (or returns the existing one with HTTP 200 if an organization
8+
with the same `imsOrgId` already exists).
9+
10+
**Auto-entitlement (via `x-product` header):** When the caller supplies an `x-product`
11+
header (e.g. `ASO`), a `FREE_TRIAL` entitlement for that product is created for the
12+
organization. The underlying TierClient is idempotent — existing entitlements for the
13+
same `(orgId, productCode)` pair are reused — so retries are safe. This brings POST
14+
`/organizations` in line with the entitlement model so that downstream product-scoped
15+
APIs can resolve the organization without a separate manual provisioning step. If the
16+
entitlement step fails (e.g. transient DB error), the endpoint returns HTTP 500 even
17+
though the org itself was persisted; the caller should retry. Without the header, no
18+
entitlement is created (backward-compatible for callers that manage entitlements
19+
separately).
820
operationId: createOrganization
21+
parameters:
22+
- $ref: './parameters.yaml#/xProduct'
923
requestBody:
1024
required: true
1125
content:
1226
application/json:
1327
schema:
1428
$ref: './schemas.yaml#/OrganizationCreate'
1529
responses:
30+
'200':
31+
description: Existing organization returned (idempotent create — an organization with the same `imsOrgId` already exists)
32+
content:
33+
application/json:
34+
schema:
35+
$ref: './schemas.yaml#/Organization'
1636
'201':
1737
description: Organization created successfully
1838
content:

docs/openapi/parameters.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,19 @@ xPromiseToken:
397397
type: string
398398
example: eyJhbGciOiJSUzI1NiIs...
399399

400+
xProduct:
401+
name: x-product
402+
description: |
403+
Product code identifying which Spacecat product the call is scoped to (e.g. `ASO`, `LLMO`).
404+
Used both for read-time filtering (e.g. listing only sites enrolled in the requested product)
405+
and write-time tier-model compliance (e.g. auto-creating a `FREE_TRIAL` entitlement and site
406+
enrollment when a site/organization is created).
407+
in: header
408+
required: false
409+
schema:
410+
type: string
411+
example: ASO
412+
400413
suggestionView:
401414
name: view
402415
description: |

docs/openapi/sites-api.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,21 @@ sites:
8787
- Schema prepended if missing
8888
8989
This idempotent behavior allows clients to safely retry site creation requests without creating duplicates.
90+
91+
**Auto-enrollment (via `x-product` header):** When the caller supplies an `x-product` header
92+
(e.g. `ASO`), the site is auto-enrolled into a `FREE_TRIAL` entitlement for that product
93+
(the org-level entitlement is created if missing, and a `SiteEnrollment` is created linking
94+
the site to it). This keeps the site visible in product-scoped listing endpoints such as
95+
`GET /organizations/{organizationId}/sites` without a separate onboarding step.
96+
The underlying TierClient is idempotent — existing entitlements/enrollments are reused — so
97+
retries are safe. If the entitlement/enrollment step fails (e.g. transient DB error), the
98+
endpoint returns HTTP 500 even though the site itself was persisted; the caller should retry
99+
(the site lookup will return the already-persisted site). Without the header, no
100+
entitlement/enrollment is created (backward-compatible for callers that manage enrollment
101+
separately).
90102
operationId: createSite
103+
parameters:
104+
- $ref: './parameters.yaml#/xProduct'
91105
requestBody:
92106
required: true
93107
content:

src/controllers/organizations.js

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import {
1414
createResponse,
1515
badRequest,
16+
internalServerError,
1617
notFound,
1718
ok, forbidden,
1819
} from '@adobe/spacecat-shared-http-utils';
@@ -22,6 +23,8 @@ import {
2223
isString,
2324
isValidUUID,
2425
} from '@adobe/spacecat-shared-utils';
26+
import { Entitlement as EntitlementModel } from '@adobe/spacecat-shared-data-access';
27+
import TierClient from '@adobe/spacecat-shared-tier-client';
2528

2629
import { OrganizationDto } from '../dto/organization.js';
2730
import { ProjectDto } from '../dto/project.js';
@@ -57,27 +60,73 @@ function OrganizationsController(ctx, env) {
5760

5861
const accessControlUtil = AccessControlUtil.fromContext(ctx);
5962

63+
/**
64+
* Ensures the organization has an entitlement for the given product.
65+
* `TierClient.createEntitlement` is idempotent at the library level: an existing
66+
* entitlement is returned as-is, otherwise a new one is created. Errors (invalid tier,
67+
* DB failures) bubble up to the caller.
68+
*
69+
* Triggered when callers pass an `x-product` header on POST /organizations; without the
70+
* header the call is a no-op (preserves backward compatibility for existing integrations).
71+
*
72+
* @param {object} context - Request context (forwarded to TierClient).
73+
* @param {object} organization - The newly created or existing organization entity.
74+
* @param {string} productCode - Product code from the `x-product` header.
75+
*/
76+
const ensureOrgEntitlement = async (context, organization, productCode) => {
77+
const { log } = ctx;
78+
const tierClient = await TierClient.createForOrg(context, organization, productCode);
79+
const { entitlement } = await tierClient.createEntitlement(
80+
EntitlementModel.TIERS.FREE_TRIAL,
81+
);
82+
log.info(`Ensured ${productCode} entitlement ${entitlement.getId()} for organization ${organization.getId()}`);
83+
};
84+
6085
/**
6186
* Creates an organization. The organization ID is generated automatically.
87+
*
88+
* Tier-model compliance: when the caller provides an `x-product` header, a FREE_TRIAL
89+
* entitlement for that product is created for the organization (idempotent — existing
90+
* entitlements are reused). This brings POST /organizations in line with the entitlement
91+
* model so that downstream product-scoped APIs can resolve the org without a separate
92+
* manual provisioning step. Entitlement failures return 500 — `TierClient.createEntitlement`
93+
* is idempotent so retries are safe.
94+
*
6295
* @param {object} context - Context of the request.
6396
* @return {Promise<Response>} Organization response.
6497
*/
6598
const createOrganization = async (context) => {
6699
if (!accessControlUtil.hasAdminAccess()) {
67100
return forbidden('Only admins can create new Organizations');
68101
}
102+
const productCode = context.pathInfo?.headers?.['x-product'];
103+
let organization;
104+
let status;
69105
// check if the organization already exists
70-
const organization = await Organization.findByImsOrgId(context.data.imsOrgId);
71-
if (organization) {
72-
return createResponse(OrganizationDto.toJSON(organization), 200);
106+
const existingOrganization = await Organization.findByImsOrgId(context.data.imsOrgId);
107+
if (existingOrganization) {
108+
organization = existingOrganization;
109+
status = 200;
110+
} else {
111+
try {
112+
organization = await Organization.create(context.data);
113+
status = 201;
114+
} catch (e) {
115+
return badRequest(e.message);
116+
}
73117
}
74118

75-
try {
76-
const organizationCreated = await Organization.create(context.data);
77-
return createResponse(OrganizationDto.toJSON(organizationCreated), 201);
78-
} catch (e) {
79-
return badRequest(e.message);
119+
if (hasText(productCode)) {
120+
try {
121+
await ensureOrgEntitlement(context, organization, productCode);
122+
} catch (error) {
123+
const { log } = ctx;
124+
log.error(`Error ensuring ${productCode} entitlement for organization ${organization.getId()}: ${error.message}`, error);
125+
return internalServerError(`Failed to ensure ${productCode} entitlement for organization`);
126+
}
80127
}
128+
129+
return createResponse(OrganizationDto.toJSON(organization), status);
81130
};
82131

83132
/**

src/controllers/sites.js

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import {
3232
canonicalizeUrl,
3333
composeBaseURL,
3434
} from '@adobe/spacecat-shared-utils';
35-
import { Site as SiteModel } from '@adobe/spacecat-shared-data-access';
35+
import { Site as SiteModel, Entitlement as EntitlementModel } from '@adobe/spacecat-shared-data-access';
3636
import { Config } from '@adobe/spacecat-shared-data-access/src/models/site/config.js';
3737

3838
import RUMAPIClient from '@adobe/spacecat-shared-rum-api-client';
@@ -272,6 +272,28 @@ function SitesController(ctx, log, env) {
272272

273273
const accessControlUtil = AccessControlUtil.fromContext(ctx);
274274

275+
/**
276+
* Ensures the org has an entitlement for the given product and the site is enrolled
277+
* under it. `TierClient.createEntitlement` is idempotent at the library level: existing
278+
* entitlements/enrollments are returned as-is, missing pieces are filled in. Errors
279+
* (invalid tier, DB failures) bubble up to the caller.
280+
*
281+
* Triggered when callers pass an `x-product` header on POST /sites; without the header
282+
* the call is a no-op (preserves backward compatibility for existing integrations).
283+
*
284+
* @param {object} context - Request context (forwarded to TierClient).
285+
* @param {object} site - The newly created or existing site entity.
286+
* @param {string} productCode - Product code from the `x-product` header.
287+
*/
288+
const ensureSiteEntitlementAndEnrollment = async (context, site, productCode) => {
289+
const tierClient = await TierClient.createForSite(context, site, productCode);
290+
const {
291+
entitlement,
292+
siteEnrollment,
293+
} = await tierClient.createEntitlement(EntitlementModel.TIERS.FREE_TRIAL);
294+
log.info(`Ensured ${productCode} entitlement ${entitlement.getId()} and enrollment ${siteEnrollment?.getId()} for site ${site.getId()}`);
295+
};
296+
275297
/**
276298
* Creates a new site or returns an existing one if a site with the same baseURL already exists.
277299
* Implements idempotent-create semantics.
@@ -286,6 +308,13 @@ function SitesController(ctx, log, env) {
286308
*
287309
* Alternative: If strict REST semantics are preferred, 409 Conflict is also valid.
288310
*
311+
* Tier-model compliance: when the caller provides an `x-product` header, the site is
312+
* auto-enrolled into a FREE_TRIAL entitlement for that product (the org-level entitlement
313+
* is created if missing). This keeps the site visible in product-scoped listing endpoints
314+
* (e.g. `GET /organizations/:organizationId/sites`) without a separate manual onboarding
315+
* step. Enrollment failures return 500 — `TierClient.createEntitlement` is idempotent so
316+
* retries are safe (the site lookup will return the already-persisted site).
317+
*
289318
* @param {object} context - Request context containing site data
290319
* @returns {Promise<Response>} HTTP 200 with existing site or 201 with new site
291320
*/
@@ -296,27 +325,43 @@ function SitesController(ctx, log, env) {
296325
if (!hasText(context.data?.baseURL)) {
297326
return badRequest('Base URL required');
298327
}
328+
const productCode = context.pathInfo?.headers?.['x-product'];
329+
let site;
330+
let status;
299331
try {
300332
const baseURL = composeBaseURL(context.data.baseURL);
301333
const existingSite = await Site.findByBaseURL(baseURL);
302334
if (existingSite) {
303335
// Idempotent behavior: return existing site with 200 (not 409)
304336
log.info(`Site already exists for baseURL: ${baseURL}, returning existing site ${existingSite.getId()}`);
305-
return createResponse(SiteDto.toJSON(existingSite), 200);
337+
site = existingSite;
338+
status = 200;
339+
} else {
340+
site = await Site.create({
341+
organizationId: env.DEFAULT_ORGANIZATION_ID,
342+
...context.data,
343+
baseURL, // override with normalized value
344+
});
345+
updateRumConfig(site, context).catch((e) => {
346+
log.warn(`[sites] RUM config update failed for ${site.getBaseURL()}: ${e.message}`);
347+
});
348+
status = 201;
306349
}
307-
const site = await Site.create({
308-
organizationId: env.DEFAULT_ORGANIZATION_ID,
309-
...context.data,
310-
baseURL, // override with normalized value
311-
});
312-
updateRumConfig(site, context).catch((e) => {
313-
log.warn(`[sites] RUM config update failed for ${site.getBaseURL()}: ${e.message}`);
314-
});
315-
return createResponse(SiteDto.toJSON(site), 201);
316350
} catch (error) {
317351
log.error(`Error creating site: ${error.message}`, error);
318352
return internalServerError('Failed to create site');
319353
}
354+
355+
if (hasText(productCode)) {
356+
try {
357+
await ensureSiteEntitlementAndEnrollment(context, site, productCode);
358+
} catch (error) {
359+
log.error(`Error ensuring ${productCode} entitlement/enrollment for site ${site.getId()}: ${error.message}`, error);
360+
return internalServerError(`Failed to ensure ${productCode} entitlement/enrollment for site`);
361+
}
362+
}
363+
364+
return createResponse(SiteDto.toJSON(site), status);
320365
};
321366

322367
/**

0 commit comments

Comments
 (0)