Currently, lib-creativitiesjs loads enhancement bundles (JS/CSS) by constructing URLs via convention:
// helpers.js:49 — TODO comment already exists acknowledging this debt
const path = `${context.staticContent}/contents/${enhancementName}/index.js`;The enhancement name comes from CREMA's spot response (variant.advanced.enhancementName), but the actual asset paths are NEVER resolved by the backend. The frontend guesses the URL shape.
Meanwhile, the asset paths DO exist in the system — they're stored in Job.assets.zipAssets[].blobInformation.path after upload — but this data is completely disconnected from the Spot/enhancement flow.
The user's requirement: "cuando se entrega un contenido, ademas de la data del contenido tiene que llegar los enhancements" — when content is delivered, enhancement asset paths must come resolved from the backend.
sequenceDiagram
participant Dev as Developer
participant GH as GitHub Actions
participant Stories as lib-creativitiesstories
participant CREMA as mic-creativitiesmanager
participant Azure as Azure Blob Storage
participant DB as MongoDB
Dev->>GH: Push to main branch
GH->>Stories: Checkout + npm ci + build (rspack)
Stories->>Stories: rspack outputs dist/{name}/index.js
GH->>Stories: node bin/createJob.js
Stories->>Stories: Zip dist/{name}/ folder
Stories->>CREMA: POST /api/v1/jobs/uploadZip (zip file)
CREMA->>CREMA: Unzip, extract files
loop Each file in zip
CREMA->>Azure: uploadFile(filename, buffer, destPath)
Azure-->>CREMA: { blobInformation, path: "destPath/filename" }
end
CREMA->>DB: Upsert Job record (assets with blobInfo paths)
Note over CREMA,DB: NEW: Enhancement auto-registration
CREMA->>CREMA: Detect index.js in zip assets
CREMA->>DB: Enhancement.findOneAndUpdate({ name: downloadName },<br/>{ assets: { js: path }, jobId }, { upsert: true })
DB-->>CREMA: Enhancement record created/updated
CREMA-->>Stories: 200 OK (Job response)
GH-->>Dev: Deploy workflow complete
sequenceDiagram
participant Editor as SPA Editor
participant CREMA as mic-creativitiesmanager
participant DB as MongoDB
participant CMNG as Content Manager (cmng)
Editor->>CREMA: POST /spots/{id}/publish
CREMA->>DB: SpotRepository.findById(id) + populate
CREMA->>CREMA: _publicationValidate(spot)
CREMA->>CREMA: obtainESpots(spot) → espots, references
Note over CREMA,DB: generateSpotSnapshot()
CREMA->>DB: SpotSnapshotRepository.findOne (previous)
CREMA->>DB: ConfigurationRepository.findByQuery (customizations)
CREMA->>CREMA: _reduceVariants() → variants, contentsMap, etc.
Note over CREMA,DB: NEW: _resolveEnhancementAssets()
CREMA->>CREMA: Collect enhancementNames from variants
CREMA->>DB: Enhancement.find({ name: { $in: [...names] } })
DB-->>CREMA: [{ name, assets: { js, css } }]
CREMA->>CREMA: Inject enhancementAssets into variant.advanced
CREMA->>CREMA: Build spotSnapshot (with enriched variants)
Note over CREMA,CMNG: Push to Content Manager
CREMA->>CREMA: getCmngTemplate(spotSnapshot) → serialize to EJS
CREMA->>CMNG: updateResource("content", contentId, payload)
CMNG-->>CREMA: 200 OK
CREMA->>DB: SpotRepository.update (metadata.publishedAt)
CREMA->>DB: SpotSnapshotService.saveSpotSnapshot()
CREMA-->>Editor: { message: "Spot published" }
sequenceDiagram
participant User as End User Browser
participant FE as lib-creativitiesjs
participant CMNG as Content Manager (cmng)
participant CDN as Azure CDN / Blob
User->>FE: Page load → init creativities
FE->>CMNG: GET spot content (via getSpot)
CMNG-->>FE: Spot JSON response
Note over FE: Response includes:<br/>variant.advanced = {<br/> enhancement: true,<br/> enhancementName: "ss26-joinlife-care",<br/> enhancementAssets: {<br/> js: "ss26-joinlife-care/index.js"<br/> }<br/>}
FE->>FE: loadEnhancement({ name, assets }, context)
alt Assets resolved (new behavior)
FE->>FE: jsPath = staticContent/contents/{assets.js}
else Fallback (legacy behavior)
FE->>FE: jsPath = staticContent/contents/{name}/index.js
end
FE->>CDN: <script src="{jsPath}">
CDN-->>FE: Enhancement JS bundle
opt CSS asset available
FE->>CDN: <link href="{cssPath}">
CDN-->>FE: Enhancement CSS
end
FE->>FE: window.zara.enhancements[name].default
FE->>User: Render enhancement component
┌─────────────────────────┐
│ lib-creativitiesstories │
│ (rspack build) │
│ dist/{name}/index.js │
└───────────┬─────────────┘
│ bin/deploy (LOCAL)
│ → createJob.js
│ → POST /api/v1/jobs/uploadZip
▼
┌─────────────────────────────────────────────────────┐
│ mic-creativitiesmanager │
│ │
│ uploadZip.js: │
│ → unzip → upload each file to Azure Blob Storage │
│ → creates Job record with assets[].blobInfo.path │
│ │
│ Azure path: static/contents/{destPath}/{filename} │
│ Stored as: job.assets.zipAssets[].blobInfo.path │
│ = "{destPath}/{filename}" │
└───────────┬─────────────────────────────────────────┘
│ (NO CONNECTION)
▼
┌─────────────────────────────────────────────────────┐
│ Spot model │
│ variant.advanced = { │
│ enhancement: true, │
│ enhancementName: "ss26-joinlife-care" │
│ } │
│ (NO asset paths stored here) │
└───────────┬─────────────────────────────────────────┘
│ publish → generateSpotSnapshot
│ → spotSnapshot serialized into EJS template
│ → pushed to cmng (Content Manager)
▼
┌─────────────────────────────────────────────────────┐
│ Frontend (lib-creativitiesjs) │
│ │
│ Receives spot with: enhancement.name = "xxx" │
│ Constructs URL: staticContent/contents/xxx/index.js │
│ Loads via <script> tag │
└─────────────────────────────────────────────────────┘
The GAP: Job has the paths. Spot has the name. Nothing connects them.
┌─────────────────────────┐
│ lib-creativitiesstories │
│ (rspack build) │
│ dist/{name}/index.js │
└───────────┬─────────────┘
│ GitHub Action (REPLACES local bin/deploy)
│ → createJob.js
│ → POST /api/v1/jobs/uploadZip
▼
┌─────────────────────────────────────────────────────┐
│ mic-creativitiesmanager │
│ │
│ uploadZip.js: │
│ → unzip → upload to Azure │
│ → creates Job record (unchanged) │
│ → NEW: if zip has index.js, upsert Enhancement │
│ │
│ NEW Enhancement model: │
│ { │
│ name: "ss26-joinlife-care", │
│ assets: { js: "ss26-joinlife-care/index.js" }, │
│ jobId: ObjectId, │
│ uploadedAt: Date │
│ } │
└───────────┬─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ generateSpotSnapshot.js │
│ │
│ When building variant with enhancement: │
│ → lookup Enhancement by enhancementName │
│ → inject enhancementAssets into variant.advanced │
│ │
│ Snapshot variant.advanced becomes: │
│ { │
│ enhancement: true, │
│ enhancementName: "ss26-joinlife-care", │
│ enhancementAssets: { │
│ js: "ss26-joinlife-care/index.js", │
│ css: null │
│ } │
│ } │
└───────────┬─────────────────────────────────────────┘
│ existing serialization → cmng
▼
┌─────────────────────────────────────────────────────┐
│ Frontend (lib-creativitiesjs) │
│ │
│ Receives: enhancement.assets.js resolved path │
│ Builds URL: staticContent/contents/{assets.js} │
│ Fallback: convention if assets not present │
└─────────────────────────────────────────────────────┘
Repo: mic-creativitiesmanager
File: code/src/models/Enhancement.js (NEW)
const mongoose = require("mongoose");
const { Schema, model } = mongoose;
const EnhancementSchema = new Schema(
{
name: {
type: String,
required: true,
unique: true,
index: true,
trim: true,
},
assets: {
type: new Schema(
{
js: { type: String, trim: true },
css: { type: String, trim: true },
},
{ _id: false, minimize: false },
),
required: true,
},
jobId: {
type: Schema["ObjectId"],
ref: "Job",
},
uploadedAt: {
type: Date,
default: Date.now,
},
},
{ minimize: false, timestamps: true },
);
const Enhancement = model("Enhancement", EnhancementSchema);
module.exports = Enhancement;Purpose: Explicit registry mapping enhancement names to their resolved asset paths. Decouples the "what paths exist" knowledge from the Job model (which is a generic upload record).
Why a separate model instead of adding fields to Spot:
- The same enhancement can be referenced by multiple spots/variants
- The enhancement lifecycle (upload/redeploy) is independent from spot lifecycle (publish/unpublish)
- Avoids fan-out updates to N spots when an enhancement is redeployed
Repo: mic-creativitiesmanager
File: code/src/api/job/service/uploadZip.js
Change location: After successful Azure upload (lines 116-125), add Enhancement upsert logic.
Current code (lines 115-125):
logger.info(`File ${file.originalFilename} uploaded to azure`);
await _upsertJob({
storedJob: updatedJob,
assets,
downloadName: updatedJob.downloadName,
// ...
status: STATUS.completed,
});Modified code:
logger.info(`File ${file.originalFilename} uploaded to azure`);
const completedJob = await _upsertJob({
storedJob: updatedJob,
assets,
downloadName: updatedJob.downloadName,
// ...
status: STATUS.completed,
});
// Auto-register enhancement if zip contains index.js
await _registerEnhancementIfApplicable(assets, updatedJob.downloadName, completedJob._id);New helper function:
const Enhancement = require("../../../models/Enhancement");
const _registerEnhancementIfApplicable = async (assets, downloadName, jobId) => {
const jsAsset = assets.zipAssets.find(a => a.filename === "index.js");
if (!jsAsset) return;
const cssAsset = assets.zipAssets.find(a => a.filename === "index.css");
await Enhancement.findOneAndUpdate(
{ name: downloadName },
{
name: downloadName,
assets: {
js: jsAsset.blobInformation.path,
...(cssAsset ? { css: cssAsset.blobInformation.path } : {}),
},
jobId,
uploadedAt: new Date(),
},
{ upsert: true, new: true },
);
logger.info(`Enhancement "${downloadName}" registered with assets: js=${jsAsset.blobInformation.path}`);
};Detection heuristic: If the uploaded zip contains a file named index.js, treat it as an enhancement bundle. This matches the rspack output convention from stories (dist/{name}/index.js).
Why this heuristic works:
- Stories outputs:
dist/ss26-joinlife-care/index.js→ zip root hasindex.jsinside folder - Regular content uploads (images, fonts, PDFs) won't have an
index.js - The zip filename (=
downloadName) equals the enhancement name by convention increateJob.js
Edge case: If a non-enhancement zip happens to contain index.js, a spurious Enhancement record is created. This is harmless — it won't be consumed unless a Spot explicitly references that name in variant.advanced.enhancementName.
Repo: mic-creativitiesmanager
File: code/src/api/spotSnapshot/shared/generateSpotSnapshot.js
Change location: After _reduceVariants() returns filteredVariants (line 271-273 area), enrich variants with enhancement assets.
Current code (lines 71-76):
snapshot.variants = variants;
snapshot.contentsMap = Object.values(contentsMap);
snapshot.rulesMap = Object.values(rulesMap);
snapshot.entriesMap = Object.values(entriesMap);
snapshot.isMultiSpot = isMultiSpot;
return snapshot;Modified code:
snapshot.variants = await _resolveEnhancementAssets(variants);
snapshot.contentsMap = Object.values(contentsMap);
snapshot.rulesMap = Object.values(rulesMap);
snapshot.entriesMap = Object.values(entriesMap);
snapshot.isMultiSpot = isMultiSpot;
return snapshot;New helper function:
const Enhancement = require("../../../models/Enhancement");
const _resolveEnhancementAssets = async (variants) => {
// Collect unique enhancement names to batch-query
const enhancementNames = [
...new Set(
variants
.filter(v => v.advanced?.enhancement && v.advanced?.enhancementName)
.map(v => v.advanced.enhancementName),
),
];
if (!enhancementNames.length) return variants;
// Single batch query for all enhancement names
const enhancements = await Enhancement.find({ name: { $in: enhancementNames } }).lean();
const enhancementMap = enhancements.reduce((map, e) => {
map[e.name] = e.assets;
return map;
}, {});
// Inject resolved assets into each variant
return variants.map(variant => {
if (variant.advanced?.enhancement && variant.advanced?.enhancementName) {
const assets = enhancementMap[variant.advanced.enhancementName];
if (assets) {
return {
...variant,
advanced: {
...variant.advanced,
enhancementAssets: assets,
},
};
}
}
return variant;
});
};Performance note: Uses a single $in query for all enhancement names in the spot (typically 1-2 names). Negligible overhead vs the existing N content/rule/entry queries in the same function.
Repo: mic-creativitiesmanager
File: code/src/models/SpotSnapshot.js
Change location: The variant.advanced schema (lines 98-113).
Current:
advanced: {
type: new Schema(
{
enhancement: { type: Boolean, default: false },
enhancementName: {
type: String,
validate: { validator: whiteSpaceValidator, message: ... },
},
},
{ _id: false, minimize: false },
),
default: {},
},Modified (add enhancementAssets):
advanced: {
type: new Schema(
{
enhancement: { type: Boolean, default: false },
enhancementName: {
type: String,
validate: { validator: whiteSpaceValidator, message: ... },
},
enhancementAssets: {
type: new Schema(
{
js: { type: String, trim: true },
css: { type: String, trim: true },
},
{ _id: false, minimize: false },
),
},
},
{ _id: false, minimize: false },
),
default: {},
},Repo: lib-creativitiesjs
File: code/src/helpers.js
Current loadEnhancement (lines 46-64):
export const loadEnhancement = (enhancement, context) => {
return new Promise((resolve, reject) => {
// TODO: debería venir la ruta del js y el css resuelta en la respuesta del spot
const path = `${context.staticContent}/contents/${enhancement}/index.js`;
const script = document.createElement("script");
script.type = "text/javascript";
script.src = path;
script.onload = () => {
const enhancementName = enhancement.split("/").pop();
resolve(window.zara.enhancements[enhancementName].default);
};
script.onerror = err => reject(err);
document.head.appendChild(script);
});
};Modified:
/**
* Loads an enhancement bundle.
* @param {Object|string} enhancement - Enhancement config object or name (legacy)
* @param {string} enhancement.name - Enhancement name
* @param {Object} [enhancement.assets] - Resolved asset paths from backend
* @param {string} [enhancement.assets.js] - JS bundle relative path
* @param {string} [enhancement.assets.css] - CSS bundle relative path
* @param {Object} context - Rendering context with staticContent base URL
*/
export const loadEnhancement = (enhancement, context) => {
return new Promise((resolve, reject) => {
const basePath = `${context.staticContent}/contents`;
// Support both new object format and legacy string format
const name = typeof enhancement === "string" ? enhancement : enhancement.name;
const assets = typeof enhancement === "string" ? null : enhancement.assets;
// Use resolved path from backend, fallback to convention
const jsPath = assets?.js
? `${basePath}/${assets.js}`
: `${basePath}/${name}/index.js`;
// Load CSS if resolved path available
if (assets?.css) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = `${basePath}/${assets.css}`;
document.head.appendChild(link);
}
const script = document.createElement("script");
script.type = "text/javascript";
script.src = jsPath;
script.onload = () => {
const enhancementKey = name.split("/").pop();
resolve(window.zara.enhancements[enhancementKey].default);
};
script.onerror = err => reject(err);
document.head.appendChild(script);
});
};File: code/src/components/Creativity/creativityHelpers.js
Current (line ~143 in getContents):
const enhancementModule = await loadEnhancement(content.project.enhancement.name, context);Modified:
const enhancementModule = await loadEnhancement({
name: content.project.enhancement.name,
assets: content.project.enhancement.assets,
}, context);Same pattern for lines ~256 and ~289 in resolveMixedContents().
File: code/src/components/Creativity/useCreativity.js (line ~35):
Current:
const module = await loadEnhancement(data.externalBundle, context);Modified:
const module = await loadEnhancement(
typeof data.externalBundle === "string"
? data.externalBundle
: { name: data.externalBundle.name, assets: data.externalBundle.assets },
context,
);Repo: lib-creativitiesstories
File: .github/workflows/deploy.yml (NEW)
name: Deploy Enhancements
on:
push:
branches: [main]
paths:
- "code/src/**"
- "code/rspack.config.js"
- "code/package.json"
workflow_dispatch:
inputs:
environment:
description: "Target environment"
required: true
default: "pre"
type: choice
options:
- pre
- pro
env:
NODE_VERSION: "22"
jobs:
build-and-deploy:
runs-on: ubuntu-latest
environment: ${{ github.event.inputs.environment || 'pre' }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "npm"
cache-dependency-path: code/package-lock.json
- name: Install dependencies
run: npm ci
working-directory: code
- name: Lint
run: npm run lint
working-directory: code
- name: Build
run: npm run build
working-directory: code
- name: Deploy to CREMA
run: node bin/createJob.js
working-directory: code
env:
CREMA_URL: ${{ secrets.CREMA_URL }}
CREMA_USER: ${{ secrets.CREMA_USER }}
CREMA_PASSWORD: ${{ secrets.CREMA_PASSWORD }}Also modify: code/bin/deploy — add deprecation warning at the top:
echo "⚠️ WARNING: Local deploy is deprecated. Use GitHub Actions instead."
echo " Push to main branch or trigger workflow_dispatch manually."
echo ""1. Developer pushes to lib-creativitiesstories main branch
2. GH Action triggers: npm ci → lint → build (rspack) → node bin/createJob.js
3. createJob.js: zips dist/ss26-joinlife-care/ → POST /api/v1/jobs/uploadZip
4. uploadZip.js:
a. Unzips → uploads each file to Azure Blob Storage
b. Azure returns: blobInformation.path = "ss26-joinlife-care/index.js"
c. Creates/updates Job record with assets
d. NEW: Detects index.js → upserts Enhancement { name: "ss26-joinlife-care", assets: { js: "ss26-joinlife-care/index.js" } }
5. Editor publishes spot in SPA (variant has enhancement: true, enhancementName: "ss26-joinlife-care")
6. spot/service.js → _publishInSession() → generateSpotSnapshot()
7. generateSpotSnapshot.js:
a. Builds snapshot as before
b. NEW: _resolveEnhancementAssets() → queries Enhancement model
c. Injects enhancementAssets into variant.advanced
8. Snapshot serialized → EJS template → pushed to cmng
9. cmng stores and serves the content
10. User visits page → frontend requests spot from cmng
11. Response includes: variant.advanced.enhancementAssets.js = "ss26-joinlife-care/index.js"
12. lib-creativitiesjs receives spot data
13. loadEnhancement() reads assets.js → builds full URL → loads via <script>
14. Enhancement module executes
| Scenario | Behavior |
|---|---|
| Old stories deploy (no Enhancement record exists) | Frontend falls back to convention URL — works as before |
| New stories deploy, old frontend | Frontend ignores enhancementAssets field, uses convention — works as before |
| New stories deploy, new frontend, old spot snapshot (not re-published) | Frontend sees no enhancementAssets in response, uses convention — works as before |
| New stories deploy, new frontend, re-published spot | Frontend uses resolved enhancementAssets.js path — new behavior |
Zero-downtime migration path: Deploy backend → re-publish affected spots → deploy frontend. Any order is safe due to fallbacks.
| Layer | Rollback action |
|---|---|
| Frontend (lib-creativitiesjs) | Revert to previous version — convention mode resumes |
| Backend (mic-creativitiesmanager) | Remove Enhancement lookup from generateSpotSnapshot — snapshots stop including assets |
| Enhancement model | Leave in place (harmless) or drop collection |
| GitHub Action (stories) | Disable workflow — deploy locally with bin/deploy as before |
Enhancementmodel: CRUD, unique constraint on name, upsert behavioruploadZip.js: Verify Enhancement upserted when zip hasindex.js; NOT upserted when zip lacksindex.jsgenerateSpotSnapshot.js: VerifyenhancementAssetspresent when Enhancement exists; absent when not found
loadEnhancement: with assets object → uses resolved pathloadEnhancement: with string (legacy) → uses convention pathloadEnhancement: with assets = null → uses convention path- CSS loading: link element created when
assets.csspresent
- Upload zip with
index.js→ verify Enhancement record created - Publish spot with
enhancementNamematching Enhancement → verify snapshot containsenhancementAssets - Serve spot to frontend → verify response includes paths
| # | Question | Recommendation | Impact if deferred |
|---|---|---|---|
| 1 | Should Enhancement have a REST API (CRUD endpoints)? | Yes — useful for SPA to show deployed enhancements | Low — can add later without schema changes |
| 2 | Should createJob.js explicitly pass isEnhancement flag? |
No — auto-detection via index.js is sufficient and backward compatible |
None |
| 3 | Should we support CSS bundles now? | Yes — field exists, loader handles it, cost is zero | None — field is optional |
| 4 | Should we add a version field to Enhancement for cache busting? |
Defer — current flow re-uploads same path (Azure overwrites), no versioning needed yet | Low — add when needed |
| 5 | Should old bin/deploy be deleted or deprecated? |
Deprecated with warning — some devs may need local testing | Keep as dev tool |
| Task | Effort | Risk |
|---|---|---|
| Enhancement model | Small (1 file, ~30 lines) | None |
| uploadZip modification | Small (~15 lines added) | Low — additive, existing tests cover happy path |
| generateSpotSnapshot modification | Medium (~30 lines, needs batch query) | Low — additive, fallback if Enhancement not found |
| SpotSnapshot model update | Small (5 lines) | None — additive field |
| lib-creativitiesjs changes | Medium (modify 4 files, maintain backward compat) | Medium — must verify all call sites |
| GitHub Action | Small (1 new file, standard workflow) | Low — secrets setup required |
| Testing | Medium | — |
Total: ~1-2 days implementation, ~0.5 days testing.