From 5e53757d43d0508466a210ffee2141beafbc3a5d Mon Sep 17 00:00:00 2001 From: priyankmodiPM <32540484+priyankmodiPM@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:30:08 +0530 Subject: [PATCH 01/35] Update app.js --- app.js | 88 +++++++++++++++++++++------------------------------------- 1 file changed, 32 insertions(+), 56 deletions(-) diff --git a/app.js b/app.js index 5ab128e4b4..66de816cab 100644 --- a/app.js +++ b/app.js @@ -1,61 +1,37 @@ -const express = require("express"); +// Import Express.js +const express = require('express'); + +// Create an Express app const app = express(); -const port = process.env.PORT || 3001; -app.get("/", (req, res) => res.type('html').send(html)); +// Middleware to parse JSON bodies +app.use(express.json()); + +// Set port and verify_token +const port = process.env.PORT || 3000; +const verifyToken = process.env.VERIFY_TOKEN; + +// Route for GET requests +app.get('/', (req, res) => { + const { 'hub.mode': mode, 'hub.challenge': challenge, 'hub.verify_token': token } = req.query; -const server = app.listen(port, () => console.log(`Example app listening on port ${port}!`)); + if (mode === 'subscribe' && token === verifyToken) { + console.log('WEBHOOK VERIFIED'); + res.status(200).send(challenge); + } else { + res.status(403).end(); + } +}); -server.keepAliveTimeout = 120 * 1000; -server.headersTimeout = 120 * 1000; +// Route for POST requests +app.post('/', (req, res) => { + const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19); + console.log(`\n\nWebhook received ${timestamp}\n`); + console.log(JSON.stringify(req.body, null, 2)); + res.status(200).end(); +}); -const html = ` - - - - Hello from Render! - - - - - -
- Hello from Render! -
- - -` +// Start the server +app.listen(port, () => { + console.log(`\nListening on port ${port}\n`); +}); From fdd95724d9d36cc50039d3c50dd2f0881c16c7d6 Mon Sep 17 00:00:00 2001 From: Priyank Modi Date: Fri, 26 Jun 2026 19:10:59 +0530 Subject: [PATCH 02/35] Add discount image auto-reply for WhatsApp messages When an incoming WhatsApp message contains "update the discount", the webhook automatically replies to the sender with the discount image via the WhatsApp Cloud API. Phone number ID and token are read from env vars. Co-Authored-By: Claude Sonnet 4.6 --- app.js | 50 +++++++++++++++++++++++++++++++++++++++++++++++++- render.yaml | 6 ++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/app.js b/app.js index 66de816cab..356f1ddf80 100644 --- a/app.js +++ b/app.js @@ -10,6 +10,36 @@ app.use(express.json()); // Set port and verify_token const port = process.env.PORT || 3000; const verifyToken = process.env.VERIFY_TOKEN; +const whatsappPhoneNumberId = process.env.WHATSAPP_PHONE_NUMBER_ID; +const whatsappToken = process.env.WHATSAPP_TOKEN; + +const DISCOUNT_IMAGE_URL = 'https://s7ap1.scene7.com/is/image/varun/diwali-offer2-updated'; + +async function sendImageMessage(to) { + const url = `https://graph.facebook.com/v19.0/${whatsappPhoneNumberId}/messages`; + const body = { + messaging_product: 'whatsapp', + to, + type: 'image', + image: { link: DISCOUNT_IMAGE_URL }, + }; + + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${whatsappToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`WhatsApp API error ${response.status}: ${text}`); + } + + return response.json(); +} // Route for GET requests app.get('/', (req, res) => { @@ -24,11 +54,29 @@ app.get('/', (req, res) => { }); // Route for POST requests -app.post('/', (req, res) => { +app.post('/', async (req, res) => { const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19); console.log(`\n\nWebhook received ${timestamp}\n`); console.log(JSON.stringify(req.body, null, 2)); + + // Acknowledge immediately so WhatsApp doesn't retry res.status(200).end(); + + try { + const messages = req.body?.entry?.[0]?.changes?.[0]?.value?.messages; + if (!messages?.length) return; + + for (const message of messages) { + const text = message?.text?.body ?? ''; + if (text.toLowerCase().includes('update the discount')) { + console.log(`Sending discount image to ${message.from}`); + await sendImageMessage(message.from); + console.log(`Discount image sent to ${message.from}`); + } + } + } catch (err) { + console.error('Error handling message:', err.message); + } }); // Start the server diff --git a/render.yaml b/render.yaml index 9583d53df2..5a4e5ac55d 100644 --- a/render.yaml +++ b/render.yaml @@ -8,3 +8,9 @@ services: envVars: - key: NODE_ENV value: production + - key: VERIFY_TOKEN + sync: false + - key: WHATSAPP_PHONE_NUMBER_ID + sync: false + - key: WHATSAPP_TOKEN + sync: false From 4a26ed66c7fb06a023069c5718f6fab58415d1bb Mon Sep 17 00:00:00 2001 From: priyankmodiPM <32540484+priyankmodiPM@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:20:33 +0530 Subject: [PATCH 03/35] feat: replace static discount auto-reply with GPT tool-calling assistant (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a GPT decision loop (with per-phone conversation history) that routes inbound WhatsApp messages to campaign-graphic actions — listing graphics, checking allowed edits, editing via Adobe Express, and bulk generation from an uploaded file — instead of only replying to a hardcoded "update the discount" phrase. Co-authored-by: Priyank Modi --- app.js | 218 ++++++++++-- package-lock.json | 881 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 3 +- yarn.lock | 249 +++++++------ 4 files changed, 1196 insertions(+), 155 deletions(-) create mode 100644 package-lock.json diff --git a/app.js b/app.js index 356f1ddf80..0eb789d47d 100644 --- a/app.js +++ b/app.js @@ -1,29 +1,34 @@ -// Import Express.js const express = require('express'); +const OpenAI = require('openai'); -// Create an Express app const app = express(); - -// Middleware to parse JSON bodies app.use(express.json()); -// Set port and verify_token const port = process.env.PORT || 3000; const verifyToken = process.env.VERIFY_TOKEN; const whatsappPhoneNumberId = process.env.WHATSAPP_PHONE_NUMBER_ID; const whatsappToken = process.env.WHATSAPP_TOKEN; -const DISCOUNT_IMAGE_URL = 'https://s7ap1.scene7.com/is/image/varun/diwali-offer2-updated'; +const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); -async function sendImageMessage(to) { - const url = `https://graph.facebook.com/v19.0/${whatsappPhoneNumberId}/messages`; - const body = { - messaging_product: 'whatsapp', - to, - type: 'image', - image: { link: DISCOUNT_IMAGE_URL }, - }; +// In-memory conversation history per phone number (last 20 messages kept) +const conversationHistory = new Map(); + +function getHistory(phoneNumber) { + return conversationHistory.get(phoneNumber) || []; +} + +function appendHistory(phoneNumber, role, content) { + const history = conversationHistory.get(phoneNumber) || []; + history.push({ role, content }); + if (history.length > 20) history.shift(); + conversationHistory.set(phoneNumber, history); +} + +// ── WhatsApp helpers ───────────────────────────────────────────────────────── +async function whatsappPost(body) { + const url = `https://graph.facebook.com/v19.0/${whatsappPhoneNumberId}/messages`; const response = await fetch(url, { method: 'POST', headers: { @@ -32,19 +37,140 @@ async function sendImageMessage(to) { }, body: JSON.stringify(body), }); - if (!response.ok) { const text = await response.text(); throw new Error(`WhatsApp API error ${response.status}: ${text}`); } - return response.json(); } -// Route for GET requests +function sendText(to, text) { + return whatsappPost({ messaging_product: 'whatsapp', to, type: 'text', text: { body: text } }); +} + +function sendImage(to, link) { + return whatsappPost({ messaging_product: 'whatsapp', to, type: 'image', image: { link } }); +} + +// ── GPT tool definitions ───────────────────────────────────────────────────── + +const tools = [ + { + type: 'function', + function: { + name: 'list_campaign_graphics', + description: 'List all graphics available in the current campaign', + parameters: { type: 'object', properties: {} }, + }, + }, + { + type: 'function', + function: { + name: 'ask_for_more_information', + description: 'Ask the user a clarifying question when the request is ambiguous or incomplete', + parameters: { + type: 'object', + properties: { + question: { type: 'string', description: 'The clarifying question to send to the user' }, + }, + required: ['question'], + }, + }, + }, + { + type: 'function', + function: { + name: 'check_allowed_edits', + description: 'Check what edits are permitted on the current graphic', + parameters: { type: 'object', properties: {} }, + }, + }, + { + type: 'function', + function: { + name: 'edit_graphic', + description: 'Edit the current graphic via Adobe Express API (e.g. change discount text, colors)', + parameters: { + type: 'object', + properties: { + edits: { + type: 'object', + description: 'Key-value pairs of edits to apply, e.g. { "discount_text": "70%" }', + }, + }, + required: ['edits'], + }, + }, + }, + { + type: 'function', + function: { + name: 'generate_bulk_graphics', + description: 'Generate multiple graphics from an uploaded CSV or Excel file', + parameters: { + type: 'object', + properties: { + filename: { type: 'string', description: 'Name of the uploaded CSV or Excel file' }, + }, + }, + }, + }, +]; + +// ── Action handlers (stubs — wire real APIs here) ──────────────────────────── + +async function actionListCampaignGraphics() { + // TODO: fetch from campaign API + return 'Graphics in your current campaign:\n1. Diwali Offer Banner\n2. Summer Sale Flyer\n3. New Arrival Poster'; +} + +async function actionCheckAllowedEdits() { + // TODO: fetch from Adobe Express API + return 'Edits allowed on the current graphic:\n- Discount percentage\n- Headline text\n- Background color\n- Font color'; +} + +async function actionEditGraphic(edits) { + // TODO: call Adobe Express API + const summary = Object.entries(edits).map(([k, v]) => `• ${k}: ${v}`).join('\n'); + return `Graphic updated successfully:\n${summary}`; +} + +async function actionGenerateBulkGraphics(filename) { + // TODO: parse CSV/Excel and call Adobe Express API per row + return `Bulk generation complete! Graphics created from ${filename || 'your uploaded file'}.`; +} + +// ── GPT decision engine ────────────────────────────────────────────────────── + +async function decideAction(phoneNumber, userMessage) { + const last3 = getHistory(phoneNumber).slice(-3); + + const messages = [ + { + role: 'system', + content: `You are a WhatsApp assistant for managing marketing campaign graphics via Adobe Express. +Analyze the user's message and conversation history, then call the appropriate tool. +Always call exactly one tool — never reply with plain text. +If the request is ambiguous or missing details, use ask_for_more_information.`, + }, + ...last3, + { role: 'user', content: userMessage }, + ]; + + const response = await openai.chat.completions.create({ + model: 'gpt-4o', + messages, + tools, + tool_choice: 'required', + }); + + return response.choices[0].message; +} + +// ── Webhook routes ─────────────────────────────────────────────────────────── + app.get('/', (req, res) => { const { 'hub.mode': mode, 'hub.challenge': challenge, 'hub.verify_token': token } = req.query; - if (mode === 'subscribe' && token === verifyToken) { console.log('WEBHOOK VERIFIED'); res.status(200).send(challenge); @@ -53,13 +179,11 @@ app.get('/', (req, res) => { } }); -// Route for POST requests app.post('/', async (req, res) => { const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19); console.log(`\n\nWebhook received ${timestamp}\n`); console.log(JSON.stringify(req.body, null, 2)); - // Acknowledge immediately so WhatsApp doesn't retry res.status(200).end(); try { @@ -67,19 +191,61 @@ app.post('/', async (req, res) => { if (!messages?.length) return; for (const message of messages) { - const text = message?.text?.body ?? ''; - if (text.toLowerCase().includes('update the discount')) { - console.log(`Sending discount image to ${message.from}`); - await sendImageMessage(message.from); - console.log(`Discount image sent to ${message.from}`); + const userText = message?.text?.body; + if (!userText) continue; + + const phoneNumber = message.from; + console.log(`Message from ${phoneNumber}: ${userText}`); + + appendHistory(phoneNumber, 'user', userText); + + const gptMessage = await decideAction(phoneNumber, userText); + const toolCall = gptMessage.tool_calls?.[0]; + if (!toolCall) continue; + + const action = toolCall.function.name; + const args = JSON.parse(toolCall.function.arguments || '{}'); + console.log(`GPT chose action: ${action}`, args); + + let replyText; + + switch (action) { + case 'list_campaign_graphics': + await sendText(phoneNumber, '⏳ Fetching campaign graphics...'); + replyText = await actionListCampaignGraphics(); + break; + + case 'ask_for_more_information': + replyText = args.question; + break; + + case 'check_allowed_edits': + await sendText(phoneNumber, '⏳ Checking allowed edits...'); + replyText = await actionCheckAllowedEdits(); + break; + + case 'edit_graphic': + await sendText(phoneNumber, '⏳ Applying edits to your graphic...'); + replyText = await actionEditGraphic(args.edits); + break; + + case 'generate_bulk_graphics': + await sendText(phoneNumber, '⏳ Generating graphics from your file, this may take a moment...'); + replyText = await actionGenerateBulkGraphics(args.filename); + break; + + default: + replyText = "Sorry, I couldn't figure out how to handle that request."; } + + await sendText(phoneNumber, replyText); + appendHistory(phoneNumber, 'assistant', replyText); } } catch (err) { console.error('Error handling message:', err.message); } }); -// Start the server app.listen(port, () => { console.log(`\nListening on port ${port}\n`); }); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000..05cf00a448 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,881 @@ +{ + "name": "express-hello-world", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "express-hello-world", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "express": "^5.0.0", + "openai": "^6.45.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "3.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/array-flatten/-/array-flatten-3.0.0.tgz", + "integrity": "sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA==" + }, + "node_modules/body-parser": { + "version": "2.0.1", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/body-parser/-/body-parser-2.0.1.tgz", + "integrity": "sha512-PagxbjvuPH6tv0f/kdVbFGcb79D236SLcDTs6DrQ7GizJ88S1UWP4nMXFEo/I4fdhGRGabvFfFjVGm3M7U8JwA==", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "3.1.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.5.2", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "^3.0.0", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "3.1.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser/node_modules/mime-db": { + "version": "1.40.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-db/-/mime-db-1.40.0.tgz", + "integrity": "sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser/node_modules/mime-types": { + "version": "2.1.24", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-types/-/mime-types-2.1.24.tgz", + "integrity": "sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==", + "dependencies": { + "mime-db": "1.40.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/body-parser/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.7", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.1", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/cookie-signature/-/cookie-signature-1.2.1.tgz", + "integrity": "sha512-78KWk9T26NhzXtuL26cIJ8/qNHANyJ/ZYrmEXFzUmhZdjpBv+DlWlOANRTGBt48YcyslsLrj0bMLFTmXvLRCOw==", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "4.3.6", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/etag/-/etag-1.8.1.tgz", + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/express/-/express-5.0.0.tgz", + "integrity": "sha512-V4UkHQc+B7ldh1YC84HCXHwf60M4BOMvp9rkvTUWCK5apqDC1Esnbid4wm6nFyVuDy8XMfETsJw5lsIGBWyo0A==", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.0.1", + "content-disposition": "^1.0.0", + "content-type": "~1.0.4", + "cookie": "0.6.0", + "cookie-signature": "^1.2.1", + "debug": "4.3.6", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "^2.0.0", + "fresh": "2.0.0", + "http-errors": "2.0.0", + "merge-descriptors": "^2.0.0", + "methods": "~1.1.2", + "mime-types": "^3.0.0", + "on-finished": "2.4.1", + "once": "1.4.0", + "parseurl": "~1.3.3", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "router": "^2.0.0", + "safe-buffer": "5.2.1", + "send": "^1.1.0", + "serve-static": "^2.1.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "^2.0.0", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/finalhandler": { + "version": "2.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/finalhandler/-/finalhandler-2.0.0.tgz", + "integrity": "sha512-MX6Zo2adDViYh+GcxxB1dpO43eypOGUOL12rLCOTMQv/DfIbpSJUy4oQIIZhVZkH9e+bZWKMon0XHFEju16tkQ==", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.4", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.3", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.5.2", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/iconv-lite/-/iconv-lite-0.5.2.tgz", + "integrity": "sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/methods/-/methods-1.1.2.tgz", + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-db": { + "version": "1.53.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-db/-/mime-db-1.53.0.tgz", + "integrity": "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-types/-/mime-types-3.0.0.tgz", + "integrity": "sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w==", + "dependencies": { + "mime-db": "^1.53.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.2", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/object-inspect/-/object-inspect-1.13.2.tgz", + "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openai": { + "version": "6.45.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/openai/-/openai-6.45.0.tgz", + "integrity": "sha512-5DQVNErssk0afNpTTHUm/qZPU4iKR9OYdNid8Ib4puq4gHNNvGWZht2zY4h9a8JMF949Ik6m8gQutllVPbjdnw==", + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.1.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/path-to-regexp/-/path-to-regexp-8.1.0.tgz", + "integrity": "sha512-Bqn3vc8CMHty6zuD+tG23s6v2kwxslHEhTj4eYaVKGIEB+YX/2wd0/rgXLFD9G9id9KCtbVy/3ZgmvZjpa0UdQ==", + "engines": { + "node": ">=16" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/raw-body/-/raw-body-3.0.0.tgz", + "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.6.3", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/router/-/router-2.0.0.tgz", + "integrity": "sha512-dIM5zVoG8xhC6rnSN8uoAgFARwTE7BQs8YwHEvK0VCmfxQXMaOuA1uiR1IPwsW7JyK5iTt7Od/TC9StasS2NPQ==", + "dependencies": { + "array-flatten": "3.0.0", + "is-promise": "4.0.0", + "methods": "~1.1.2", + "parseurl": "~1.3.3", + "path-to-regexp": "^8.0.0", + "setprototypeof": "1.2.0", + "utils-merge": "1.0.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/send": { + "version": "1.1.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/send/-/send-1.1.0.tgz", + "integrity": "sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA==", + "dependencies": { + "debug": "^4.3.5", + "destroy": "^1.2.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^0.5.2", + "http-errors": "^2.0.0", + "mime-types": "^2.1.35", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/send/node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/serve-static": { + "version": "2.1.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/serve-static/-/serve-static-2.1.0.tgz", + "integrity": "sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA==", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/side-channel": { + "version": "1.0.6", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/type-is/-/type-is-2.0.0.tgz", + "integrity": "sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw==", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + } + } +} diff --git a/package.json b/package.json index 43954fe280..567801e154 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "start": "node app.js" }, "dependencies": { - "express": "^5.0.0" + "express": "^5.0.0", + "openai": "^6.45.0" } } diff --git a/yarn.lock b/yarn.lock index 35c2bede4d..f4940dc9e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4,7 +4,7 @@ accepts@^2.0.0: version "2.0.0" - resolved "https://registry.yarnpkg.com/accepts/-/accepts-2.0.0.tgz#bbcf4ba5075467f3f2131eab3cffc73c2f5d7895" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/accepts/-/accepts-2.0.0.tgz" integrity sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng== dependencies: mime-types "^3.0.0" @@ -12,12 +12,12 @@ accepts@^2.0.0: array-flatten@3.0.0: version "3.0.0" - resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-3.0.0.tgz#6428ca2ee52c7b823192ec600fa3ed2f157cd541" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/array-flatten/-/array-flatten-3.0.0.tgz" integrity sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA== body-parser@^2.0.1: version "2.0.1" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-2.0.1.tgz#979de4a43468c5624403457fd6d45f797faffbaf" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/body-parser/-/body-parser-2.0.1.tgz" integrity sha512-PagxbjvuPH6tv0f/kdVbFGcb79D236SLcDTs6DrQ7GizJ88S1UWP4nMXFEo/I4fdhGRGabvFfFjVGm3M7U8JwA== dependencies: bytes "3.1.2" @@ -34,12 +34,12 @@ body-parser@^2.0.1: bytes@3.1.2: version "3.1.2" - resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/bytes/-/bytes-3.1.2.tgz" integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== call-bind@^1.0.7: version "1.0.7" - resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/call-bind/-/call-bind-1.0.7.tgz" integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== dependencies: es-define-property "^1.0.0" @@ -50,62 +50,50 @@ call-bind@^1.0.7: content-disposition@^1.0.0: version "1.0.0" - resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-1.0.0.tgz#844426cb398f934caefcbb172200126bc7ceace2" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/content-disposition/-/content-disposition-1.0.0.tgz" integrity sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg== dependencies: safe-buffer "5.2.1" -content-type@^1.0.5, content-type@~1.0.5: +content-type@^1.0.5, content-type@~1.0.4, content-type@~1.0.5: version "1.0.5" - resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/content-type/-/content-type-1.0.5.tgz" integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== -content-type@~1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" - integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== - cookie-signature@^1.2.1: version "1.2.1" - resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.2.1.tgz#790dea2cce64638c7ae04d9fabed193bd7ccf3b4" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/cookie-signature/-/cookie-signature-1.2.1.tgz" integrity sha512-78KWk9T26NhzXtuL26cIJ8/qNHANyJ/ZYrmEXFzUmhZdjpBv+DlWlOANRTGBt48YcyslsLrj0bMLFTmXvLRCOw== cookie@0.6.0: version "0.6.0" - resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/cookie/-/cookie-0.6.0.tgz" integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw== +debug@^4.3.5, debug@4.3.6: + version "4.3.6" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/debug/-/debug-4.3.6.tgz" + integrity sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg== + dependencies: + ms "2.1.2" + debug@2.6.9: version "2.6.9" - resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/debug/-/debug-2.6.9.tgz" integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== dependencies: ms "2.0.0" debug@3.1.0: version "3.1.0" - resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/debug/-/debug-3.1.0.tgz" integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== dependencies: ms "2.0.0" -debug@4.3.6: - version "4.3.6" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.6.tgz#2ab2c38fbaffebf8aa95fdfe6d88438c7a13c52b" - integrity sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg== - dependencies: - ms "2.1.2" - -debug@^4.3.5: - version "4.3.7" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" - integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== - dependencies: - ms "^2.1.3" - define-data-property@^1.1.4: version "1.1.4" - resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/define-data-property/-/define-data-property-1.1.4.tgz" integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== dependencies: es-define-property "^1.0.0" @@ -114,54 +102,54 @@ define-data-property@^1.1.4: depd@2.0.0: version "2.0.0" - resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/depd/-/depd-2.0.0.tgz" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== -destroy@1.2.0, destroy@^1.2.0: +destroy@^1.2.0, destroy@1.2.0: version "1.2.0" - resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/destroy/-/destroy-1.2.0.tgz" integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== ee-first@1.1.1: version "1.1.1" - resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" - integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ee-first/-/ee-first-1.1.1.tgz" + integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== encodeurl@^2.0.0, encodeurl@~2.0.0: version "2.0.0" - resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/encodeurl/-/encodeurl-2.0.0.tgz" integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== encodeurl@~1.0.2: version "1.0.2" - resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" - integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/encodeurl/-/encodeurl-1.0.2.tgz" + integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== es-define-property@^1.0.0: version "1.0.0" - resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/es-define-property/-/es-define-property-1.0.0.tgz" integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ== dependencies: get-intrinsic "^1.2.4" es-errors@^1.3.0: version "1.3.0" - resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/es-errors/-/es-errors-1.3.0.tgz" integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== escape-html@^1.0.3, escape-html@~1.0.3: version "1.0.3" - resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" - integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/escape-html/-/escape-html-1.0.3.tgz" + integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== etag@^1.8.1, etag@~1.8.1: version "1.8.1" - resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" - integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/etag/-/etag-1.8.1.tgz" + integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== express@^5.0.0: version "5.0.0" - resolved "https://registry.yarnpkg.com/express/-/express-5.0.0.tgz#744f9ec86025a01aeca99e4300aa4fc050d493c7" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/express/-/express-5.0.0.tgz" integrity sha512-V4UkHQc+B7ldh1YC84HCXHwf60M4BOMvp9rkvTUWCK5apqDC1Esnbid4wm6nFyVuDy8XMfETsJw5lsIGBWyo0A== dependencies: accepts "^2.0.0" @@ -199,7 +187,7 @@ express@^5.0.0: finalhandler@^2.0.0: version "2.0.0" - resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-2.0.0.tgz#9d3c79156dfa798069db7de7dd53bc37546f564b" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/finalhandler/-/finalhandler-2.0.0.tgz" integrity sha512-MX6Zo2adDViYh+GcxxB1dpO43eypOGUOL12rLCOTMQv/DfIbpSJUy4oQIIZhVZkH9e+bZWKMon0XHFEju16tkQ== dependencies: debug "2.6.9" @@ -212,27 +200,27 @@ finalhandler@^2.0.0: forwarded@0.2.0: version "0.2.0" - resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/forwarded/-/forwarded-0.2.0.tgz" integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== -fresh@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/fresh/-/fresh-2.0.0.tgz#8dd7df6a1b3a1b3a5cf186c05a5dd267622635a4" - integrity sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A== - fresh@^0.5.2: version "0.5.2" - resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/fresh/-/fresh-0.5.2.tgz" integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== +fresh@2.0.0: + version "2.0.0" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/fresh/-/fresh-2.0.0.tgz" + integrity sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A== + function-bind@^1.1.2: version "1.1.2" - resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/function-bind/-/function-bind-1.1.2.tgz" integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== get-intrinsic@^1.1.3, get-intrinsic@^1.2.4: version "1.2.4" - resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz#e385f5a4b5227d449c3eabbad05494ef0abbeadd" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/get-intrinsic/-/get-intrinsic-1.2.4.tgz" integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== dependencies: es-errors "^1.3.0" @@ -243,38 +231,38 @@ get-intrinsic@^1.1.3, get-intrinsic@^1.2.4: gopd@^1.0.1: version "1.0.1" - resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/gopd/-/gopd-1.0.1.tgz" integrity sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA== dependencies: get-intrinsic "^1.1.3" has-property-descriptors@^1.0.2: version "1.0.2" - resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz" integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== dependencies: es-define-property "^1.0.0" has-proto@^1.0.1: version "1.0.3" - resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.3.tgz#b31ddfe9b0e6e9914536a6ab286426d0214f77fd" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/has-proto/-/has-proto-1.0.3.tgz" integrity sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q== has-symbols@^1.0.3: version "1.0.3" - resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/has-symbols/-/has-symbols-1.0.3.tgz" integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== hasown@^2.0.0: version "2.0.2" - resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/hasown/-/hasown-2.0.2.tgz" integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== dependencies: function-bind "^1.1.2" -http-errors@2.0.0, http-errors@^2.0.0: +http-errors@^2.0.0, http-errors@2.0.0: version "2.0.0" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/http-errors/-/http-errors-2.0.0.tgz" integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== dependencies: depd "2.0.0" @@ -285,141 +273,146 @@ http-errors@2.0.0, http-errors@^2.0.0: iconv-lite@0.5.2: version "0.5.2" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.5.2.tgz#af6d628dccfb463b7364d97f715e4b74b8c8c2b8" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/iconv-lite/-/iconv-lite-0.5.2.tgz" integrity sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag== dependencies: safer-buffer ">= 2.1.2 < 3" iconv-lite@0.6.3: version "0.6.3" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/iconv-lite/-/iconv-lite-0.6.3.tgz" integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== dependencies: safer-buffer ">= 2.1.2 < 3.0.0" inherits@2.0.4: version "2.0.4" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/inherits/-/inherits-2.0.4.tgz" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== ipaddr.js@1.9.1: version "1.9.1" - resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ipaddr.js/-/ipaddr.js-1.9.1.tgz" integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== is-promise@4.0.0: version "4.0.0" - resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-4.0.0.tgz#42ff9f84206c1991d26debf520dd5c01042dd2f3" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/is-promise/-/is-promise-4.0.0.tgz" integrity sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ== -media-typer@0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" - integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= - media-typer@^1.1.0: version "1.1.0" - resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-1.1.0.tgz#6ab74b8f2d3320f2064b2a87a38e7931ff3a5561" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/media-typer/-/media-typer-1.1.0.tgz" integrity sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw== +media-typer@0.3.0: + version "0.3.0" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/media-typer/-/media-typer-0.3.0.tgz" + integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== + merge-descriptors@^2.0.0: version "2.0.0" - resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz#ea922f660635a2249ee565e0449f951e6b603808" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/merge-descriptors/-/merge-descriptors-2.0.0.tgz" integrity sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g== methods@~1.1.2: version "1.1.2" - resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" - integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/methods/-/methods-1.1.2.tgz" + integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== + +mime-db@^1.53.0: + version "1.53.0" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-db/-/mime-db-1.53.0.tgz" + integrity sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg== mime-db@1.40.0: version "1.40.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.40.0.tgz#a65057e998db090f732a68f6c276d387d4126c32" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-db/-/mime-db-1.40.0.tgz" integrity sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA== mime-db@1.52.0: version "1.52.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-db/-/mime-db-1.52.0.tgz" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== -mime-db@^1.53.0: - version "1.53.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.53.0.tgz#3cb63cd820fc29896d9d4e8c32ab4fcd74ccb447" - integrity sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg== - mime-types@^2.1.35: version "2.1.35" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-types/-/mime-types-2.1.35.tgz" integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== dependencies: mime-db "1.52.0" mime-types@^3.0.0: version "3.0.0" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-3.0.0.tgz#148453a900475522d095a445355c074cca4f5217" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-types/-/mime-types-3.0.0.tgz" integrity sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w== dependencies: mime-db "^1.53.0" mime-types@~2.1.24: version "2.1.24" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.24.tgz#b6f8d0b3e951efb77dedeca194cff6d16f676f81" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-types/-/mime-types-2.1.24.tgz" integrity sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ== dependencies: mime-db "1.40.0" +ms@^2.1.3: + version "2.1.3" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ms/-/ms-2.1.3.tgz" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + ms@2.0.0: version "2.0.0" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" - integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ms/-/ms-2.0.0.tgz" + integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== ms@2.1.2: version "2.1.2" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ms/-/ms-2.1.2.tgz" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -ms@^2.1.3: - version "2.1.3" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" - integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== - negotiator@^1.0.0: version "1.0.0" - resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-1.0.0.tgz#b6c91bb47172d69f93cfd7c357bbb529019b5f6a" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/negotiator/-/negotiator-1.0.0.tgz" integrity sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg== object-inspect@^1.13.1: version "1.13.2" - resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.2.tgz#dea0088467fb991e67af4058147a24824a3043ff" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/object-inspect/-/object-inspect-1.13.2.tgz" integrity sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g== -on-finished@2.4.1, on-finished@^2.4.1: +on-finished@^2.4.1, on-finished@2.4.1: version "2.4.1" - resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/on-finished/-/on-finished-2.4.1.tgz" integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== dependencies: ee-first "1.1.1" once@1.4.0: version "1.4.0" - resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/once/-/once-1.4.0.tgz" integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== dependencies: wrappy "1" +openai@^6.45.0: + version "6.45.0" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/openai/-/openai-6.45.0.tgz" + integrity sha512-5DQVNErssk0afNpTTHUm/qZPU4iKR9OYdNid8Ib4puq4gHNNvGWZht2zY4h9a8JMF949Ik6m8gQutllVPbjdnw== + parseurl@^1.3.3, parseurl@~1.3.3: version "1.3.3" - resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/parseurl/-/parseurl-1.3.3.tgz" integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== path-to-regexp@^8.0.0: version "8.1.0" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.1.0.tgz#4d687606ed0be8ed512ba802eb94d620cb1a86f0" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/path-to-regexp/-/path-to-regexp-8.1.0.tgz" integrity sha512-Bqn3vc8CMHty6zuD+tG23s6v2kwxslHEhTj4eYaVKGIEB+YX/2wd0/rgXLFD9G9id9KCtbVy/3ZgmvZjpa0UdQ== proxy-addr@~2.0.7: version "2.0.7" - resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/proxy-addr/-/proxy-addr-2.0.7.tgz" integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== dependencies: forwarded "0.2.0" @@ -427,19 +420,19 @@ proxy-addr@~2.0.7: qs@6.13.0: version "6.13.0" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.13.0.tgz#6ca3bd58439f7e245655798997787b0d88a51906" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/qs/-/qs-6.13.0.tgz" integrity sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg== dependencies: side-channel "^1.0.6" range-parser@^1.2.1, range-parser@~1.2.1: version "1.2.1" - resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/range-parser/-/range-parser-1.2.1.tgz" integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== raw-body@^3.0.0: version "3.0.0" - resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-3.0.0.tgz#25b3476f07a51600619dae3fe82ddc28a36e5e0f" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/raw-body/-/raw-body-3.0.0.tgz" integrity sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g== dependencies: bytes "3.1.2" @@ -449,7 +442,7 @@ raw-body@^3.0.0: router@^2.0.0: version "2.0.0" - resolved "https://registry.yarnpkg.com/router/-/router-2.0.0.tgz#8692720b95de83876870d7bc638dd3c7e1ae8a27" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/router/-/router-2.0.0.tgz" integrity sha512-dIM5zVoG8xhC6rnSN8uoAgFARwTE7BQs8YwHEvK0VCmfxQXMaOuA1uiR1IPwsW7JyK5iTt7Od/TC9StasS2NPQ== dependencies: array-flatten "3.0.0" @@ -462,17 +455,17 @@ router@^2.0.0: safe-buffer@5.2.1: version "5.2.1" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/safe-buffer/-/safe-buffer-5.2.1.tgz" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== "safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": version "2.1.2" - resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/safer-buffer/-/safer-buffer-2.1.2.tgz" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== send@^1.0.0, send@^1.1.0: version "1.1.0" - resolved "https://registry.yarnpkg.com/send/-/send-1.1.0.tgz#4efe6ff3bb2139b0e5b2648d8b18d4dec48fc9c5" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/send/-/send-1.1.0.tgz" integrity sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA== dependencies: debug "^4.3.5" @@ -490,7 +483,7 @@ send@^1.0.0, send@^1.1.0: serve-static@^2.1.0: version "2.1.0" - resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-2.1.0.tgz#1b4eacbe93006b79054faa4d6d0a501d7f0e84e2" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/serve-static/-/serve-static-2.1.0.tgz" integrity sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA== dependencies: encodeurl "^2.0.0" @@ -500,7 +493,7 @@ serve-static@^2.1.0: set-function-length@^1.2.1: version "1.2.2" - resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/set-function-length/-/set-function-length-1.2.2.tgz" integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== dependencies: define-data-property "^1.1.4" @@ -512,12 +505,12 @@ set-function-length@^1.2.1: setprototypeof@1.2.0: version "1.2.0" - resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/setprototypeof/-/setprototypeof-1.2.0.tgz" integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== side-channel@^1.0.6: version "1.0.6" - resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/side-channel/-/side-channel-1.0.6.tgz" integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA== dependencies: call-bind "^1.0.7" @@ -525,19 +518,19 @@ side-channel@^1.0.6: get-intrinsic "^1.2.4" object-inspect "^1.13.1" -statuses@2.0.1, statuses@^2.0.1: +statuses@^2.0.1, statuses@2.0.1: version "2.0.1" - resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/statuses/-/statuses-2.0.1.tgz" integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== toidentifier@1.0.1: version "1.0.1" - resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/toidentifier/-/toidentifier-1.0.1.tgz" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== type-is@^2.0.0: version "2.0.0" - resolved "https://registry.yarnpkg.com/type-is/-/type-is-2.0.0.tgz#7d249c2e2af716665cc149575dadb8b3858653af" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/type-is/-/type-is-2.0.0.tgz" integrity sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw== dependencies: content-type "^1.0.5" @@ -546,28 +539,28 @@ type-is@^2.0.0: type-is@~1.6.18: version "1.6.18" - resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/type-is/-/type-is-1.6.18.tgz" integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== dependencies: media-typer "0.3.0" mime-types "~2.1.24" -unpipe@1.0.0, unpipe@~1.0.0: +unpipe@~1.0.0, unpipe@1.0.0: version "1.0.0" - resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" - integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/unpipe/-/unpipe-1.0.0.tgz" + integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== utils-merge@1.0.1: version "1.0.1" - resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" - integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/utils-merge/-/utils-merge-1.0.1.tgz" + integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== vary@~1.1.2: version "1.1.2" - resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" - integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/vary/-/vary-1.1.2.tgz" + integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== wrappy@1: version "1.0.2" - resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/wrappy/-/wrappy-1.0.2.tgz" integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== From b1ed8d852219a647563fe745d217545717439205 Mon Sep 17 00:00:00 2001 From: priyankmodiPM <32540484+priyankmodiPM@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:45:36 +0530 Subject: [PATCH 04/35] fix: regenerate lockfiles against the public npm registry (#2) Co-authored-by: Priyank Modi --- yarn.lock | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/yarn.lock b/yarn.lock index f4940dc9e4..50df762342 100644 --- a/yarn.lock +++ b/yarn.lock @@ -113,7 +113,7 @@ destroy@^1.2.0, destroy@1.2.0: ee-first@1.1.1: version "1.1.1" resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ee-first/-/ee-first-1.1.1.tgz" - integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== + integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== encodeurl@^2.0.0, encodeurl@~2.0.0: version "2.0.0" @@ -123,7 +123,7 @@ encodeurl@^2.0.0, encodeurl@~2.0.0: encodeurl@~1.0.2: version "1.0.2" resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/encodeurl/-/encodeurl-1.0.2.tgz" - integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== + integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== es-define-property@^1.0.0: version "1.0.0" @@ -140,12 +140,12 @@ es-errors@^1.3.0: escape-html@^1.0.3, escape-html@~1.0.3: version "1.0.3" resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/escape-html/-/escape-html-1.0.3.tgz" - integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== + integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== etag@^1.8.1, etag@~1.8.1: version "1.8.1" resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/etag/-/etag-1.8.1.tgz" - integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== + integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== express@^5.0.0: version "5.0.0" @@ -308,7 +308,7 @@ media-typer@^1.1.0: media-typer@0.3.0: version "0.3.0" resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/media-typer/-/media-typer-0.3.0.tgz" - integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== + integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== merge-descriptors@^2.0.0: version "2.0.0" @@ -318,7 +318,7 @@ merge-descriptors@^2.0.0: methods@~1.1.2: version "1.1.2" resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/methods/-/methods-1.1.2.tgz" - integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== + integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== mime-db@^1.53.0: version "1.53.0" @@ -364,7 +364,7 @@ ms@^2.1.3: ms@2.0.0: version "2.0.0" resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ms/-/ms-2.0.0.tgz" - integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== + integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== ms@2.1.2: version "2.1.2" @@ -548,17 +548,17 @@ type-is@~1.6.18: unpipe@~1.0.0, unpipe@1.0.0: version "1.0.0" resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/unpipe/-/unpipe-1.0.0.tgz" - integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== + integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== utils-merge@1.0.1: version "1.0.1" resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/utils-merge/-/utils-merge-1.0.1.tgz" - integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== + integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== vary@~1.1.2: version "1.1.2" resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/vary/-/vary-1.1.2.tgz" - integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== + integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== wrappy@1: version "1.0.2" From f5acca1d365388b0a862f96ada46b46916529991 Mon Sep 17 00:00:00 2001 From: priyankmodiPM <32540484+priyankmodiPM@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:55:42 +0530 Subject: [PATCH 05/35] fix: regenerate yarn.lock against public npm registry (#3) yarn.lock resolved every package from Adobe's internal Artifactory (picked up via local ~/.npmrc), which Render's build servers can't reach or validate certs for, causing "unable to get local issuer certificate" during deploy. Regenerated against registry.npmjs.org and dropped package-lock.json since render.yaml only runs yarn. Co-authored-by: Priyank Modi --- package-lock.json | 881 ---------------------------------------------- yarn.lock | 671 ++++++++++++++++------------------- 2 files changed, 297 insertions(+), 1255 deletions(-) delete mode 100644 package-lock.json diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 05cf00a448..0000000000 --- a/package-lock.json +++ /dev/null @@ -1,881 +0,0 @@ -{ - "name": "express-hello-world", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "express-hello-world", - "version": "1.0.0", - "license": "MIT", - "dependencies": { - "express": "^5.0.0", - "openai": "^6.45.0" - } - }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/array-flatten": { - "version": "3.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/array-flatten/-/array-flatten-3.0.0.tgz", - "integrity": "sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA==" - }, - "node_modules/body-parser": { - "version": "2.0.1", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/body-parser/-/body-parser-2.0.1.tgz", - "integrity": "sha512-PagxbjvuPH6tv0f/kdVbFGcb79D236SLcDTs6DrQ7GizJ88S1UWP4nMXFEo/I4fdhGRGabvFfFjVGm3M7U8JwA==", - "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.5", - "debug": "3.1.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.5.2", - "on-finished": "2.4.1", - "qs": "6.13.0", - "raw-body": "^3.0.0", - "type-is": "~1.6.18", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/body-parser/node_modules/debug": { - "version": "3.1.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/body-parser/node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/body-parser/node_modules/mime-db": { - "version": "1.40.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-db/-/mime-db-1.40.0.tgz", - "integrity": "sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/body-parser/node_modules/mime-types": { - "version": "2.1.24", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-types/-/mime-types-2.1.24.tgz", - "integrity": "sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==", - "dependencies": { - "mime-db": "1.40.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, - "node_modules/body-parser/node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind": { - "version": "1.0.7", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/call-bind/-/call-bind-1.0.7.tgz", - "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/content-disposition": { - "version": "1.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/content-disposition/-/content-disposition-1.0.0.tgz", - "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.1", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/cookie-signature/-/cookie-signature-1.2.1.tgz", - "integrity": "sha512-78KWk9T26NhzXtuL26cIJ8/qNHANyJ/ZYrmEXFzUmhZdjpBv+DlWlOANRTGBt48YcyslsLrj0bMLFTmXvLRCOw==", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/debug": { - "version": "4.3.6", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/debug/-/debug-4.3.6.tgz", - "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", - "dependencies": { - "get-intrinsic": "^1.2.4" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/etag/-/etag-1.8.1.tgz", - "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express": { - "version": "5.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/express/-/express-5.0.0.tgz", - "integrity": "sha512-V4UkHQc+B7ldh1YC84HCXHwf60M4BOMvp9rkvTUWCK5apqDC1Esnbid4wm6nFyVuDy8XMfETsJw5lsIGBWyo0A==", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.0.1", - "content-disposition": "^1.0.0", - "content-type": "~1.0.4", - "cookie": "0.6.0", - "cookie-signature": "^1.2.1", - "debug": "4.3.6", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "^2.0.0", - "fresh": "2.0.0", - "http-errors": "2.0.0", - "merge-descriptors": "^2.0.0", - "methods": "~1.1.2", - "mime-types": "^3.0.0", - "on-finished": "2.4.1", - "once": "1.4.0", - "parseurl": "~1.3.3", - "proxy-addr": "~2.0.7", - "qs": "6.13.0", - "range-parser": "~1.2.1", - "router": "^2.0.0", - "safe-buffer": "5.2.1", - "send": "^1.1.0", - "serve-static": "^2.1.0", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "^2.0.0", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/finalhandler": { - "version": "2.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/finalhandler/-/finalhandler-2.0.0.tgz", - "integrity": "sha512-MX6Zo2adDViYh+GcxxB1dpO43eypOGUOL12rLCOTMQv/DfIbpSJUy4oQIIZhVZkH9e+bZWKMon0XHFEju16tkQ==", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "statuses": "2.0.1", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/finalhandler/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dependencies": { - "get-intrinsic": "^1.1.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.0.3", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/has-proto/-/has-proto-1.0.3.tgz", - "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/iconv-lite": { - "version": "0.5.2", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/iconv-lite/-/iconv-lite-0.5.2.tgz", - "integrity": "sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag==", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" - }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/methods/-/methods-1.1.2.tgz", - "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-db": { - "version": "1.53.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-db/-/mime-db-1.53.0.tgz", - "integrity": "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-types/-/mime-types-3.0.0.tgz", - "integrity": "sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w==", - "dependencies": { - "mime-db": "^1.53.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/object-inspect": { - "version": "1.13.2", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/object-inspect/-/object-inspect-1.13.2.tgz", - "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/openai": { - "version": "6.45.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/openai/-/openai-6.45.0.tgz", - "integrity": "sha512-5DQVNErssk0afNpTTHUm/qZPU4iKR9OYdNid8Ib4puq4gHNNvGWZht2zY4h9a8JMF949Ik6m8gQutllVPbjdnw==", - "peerDependencies": { - "@aws-sdk/credential-provider-node": ">=3.972.0 <4", - "@smithy/hash-node": ">=4.3.0 <5", - "@smithy/signature-v4": ">=5.4.0 <6", - "ws": "^8.18.0", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@aws-sdk/credential-provider-node": { - "optional": true - }, - "@smithy/hash-node": { - "optional": true - }, - "@smithy/signature-v4": { - "optional": true - }, - "ws": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-to-regexp": { - "version": "8.1.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/path-to-regexp/-/path-to-regexp-8.1.0.tgz", - "integrity": "sha512-Bqn3vc8CMHty6zuD+tG23s6v2kwxslHEhTj4eYaVKGIEB+YX/2wd0/rgXLFD9G9id9KCtbVy/3ZgmvZjpa0UdQ==", - "engines": { - "node": ">=16" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/qs": { - "version": "6.13.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", - "dependencies": { - "side-channel": "^1.0.6" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/raw-body/-/raw-body-3.0.0.tgz", - "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.6.3", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/raw-body/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/router": { - "version": "2.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/router/-/router-2.0.0.tgz", - "integrity": "sha512-dIM5zVoG8xhC6rnSN8uoAgFARwTE7BQs8YwHEvK0VCmfxQXMaOuA1uiR1IPwsW7JyK5iTt7Od/TC9StasS2NPQ==", - "dependencies": { - "array-flatten": "3.0.0", - "is-promise": "4.0.0", - "methods": "~1.1.2", - "parseurl": "~1.3.3", - "path-to-regexp": "^8.0.0", - "setprototypeof": "1.2.0", - "utils-merge": "1.0.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, - "node_modules/send": { - "version": "1.1.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/send/-/send-1.1.0.tgz", - "integrity": "sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA==", - "dependencies": { - "debug": "^4.3.5", - "destroy": "^1.2.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^0.5.2", - "http-errors": "^2.0.0", - "mime-types": "^2.1.35", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/send/node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/send/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/send/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, - "node_modules/serve-static": { - "version": "2.1.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/serve-static/-/serve-static-2.1.0.tgz", - "integrity": "sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA==", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" - }, - "node_modules/side-channel": { - "version": "1.0.6", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/side-channel/-/side-channel-1.0.6.tgz", - "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", - "dependencies": { - "call-bind": "^1.0.7", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/type-is": { - "version": "2.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/type-is/-/type-is-2.0.0.tgz", - "integrity": "sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw==", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/vary/-/vary-1.1.2.tgz", - "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" - } - } -} diff --git a/yarn.lock b/yarn.lock index 50df762342..e4212e3db7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4,563 +4,486 @@ accepts@^2.0.0: version "2.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/accepts/-/accepts-2.0.0.tgz" + resolved "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz#bbcf4ba5075467f3f2131eab3cffc73c2f5d7895" integrity sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng== dependencies: mime-types "^3.0.0" negotiator "^1.0.0" -array-flatten@3.0.0: - version "3.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/array-flatten/-/array-flatten-3.0.0.tgz" - integrity sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA== - -body-parser@^2.0.1: - version "2.0.1" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/body-parser/-/body-parser-2.0.1.tgz" - integrity sha512-PagxbjvuPH6tv0f/kdVbFGcb79D236SLcDTs6DrQ7GizJ88S1UWP4nMXFEo/I4fdhGRGabvFfFjVGm3M7U8JwA== +body-parser@^2.2.1: + version "2.3.0" + resolved "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz#6d8662f4d8c336028b8ac9aa24251b0ca64ba437" + integrity sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw== dependencies: - bytes "3.1.2" - content-type "~1.0.5" - debug "3.1.0" - destroy "1.2.0" - http-errors "2.0.0" - iconv-lite "0.5.2" - on-finished "2.4.1" - qs "6.13.0" - raw-body "^3.0.0" - type-is "~1.6.18" - unpipe "1.0.0" - -bytes@3.1.2: + bytes "^3.1.2" + content-type "^2.0.0" + debug "^4.4.3" + http-errors "^2.0.1" + iconv-lite "^0.7.2" + on-finished "^2.4.1" + qs "^6.15.2" + raw-body "^3.0.2" + type-is "^2.1.0" + +bytes@^3.1.2, bytes@~3.1.2: version "3.1.2" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/bytes/-/bytes-3.1.2.tgz" + resolved "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== -call-bind@^1.0.7: - version "1.0.7" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/call-bind/-/call-bind-1.0.7.tgz" - integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== +call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" + integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== dependencies: - es-define-property "^1.0.0" es-errors "^1.3.0" function-bind "^1.1.2" - get-intrinsic "^1.2.4" - set-function-length "^1.2.1" -content-disposition@^1.0.0: - version "1.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/content-disposition/-/content-disposition-1.0.0.tgz" - integrity sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg== +call-bound@^1.0.2: + version "1.0.4" + resolved "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" + integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== dependencies: - safe-buffer "5.2.1" + call-bind-apply-helpers "^1.0.2" + get-intrinsic "^1.3.0" -content-type@^1.0.5, content-type@~1.0.4, content-type@~1.0.5: +content-disposition@^1.0.0: + version "1.1.0" + resolved "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz#f3db789c752d45564cc7e9e1e0b31790d4a38e17" + integrity sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g== + +content-type@^1.0.5: version "1.0.5" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/content-type/-/content-type-1.0.5.tgz" + resolved "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== -cookie-signature@^1.2.1: - version "1.2.1" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/cookie-signature/-/cookie-signature-1.2.1.tgz" - integrity sha512-78KWk9T26NhzXtuL26cIJ8/qNHANyJ/ZYrmEXFzUmhZdjpBv+DlWlOANRTGBt48YcyslsLrj0bMLFTmXvLRCOw== - -cookie@0.6.0: - version "0.6.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/cookie/-/cookie-0.6.0.tgz" - integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw== - -debug@^4.3.5, debug@4.3.6: - version "4.3.6" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/debug/-/debug-4.3.6.tgz" - integrity sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg== - dependencies: - ms "2.1.2" +content-type@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz#2fb3ede69dffa0af78ca7c4ce7589680638b56df" + integrity sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ== -debug@2.6.9: - version "2.6.9" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/debug/-/debug-2.6.9.tgz" - integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== - dependencies: - ms "2.0.0" +cookie-signature@^1.2.1: + version "1.2.2" + resolved "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz#57c7fc3cc293acab9fec54d73e15690ebe4a1793" + integrity sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg== -debug@3.1.0: - version "3.1.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/debug/-/debug-3.1.0.tgz" - integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== - dependencies: - ms "2.0.0" +cookie@^0.7.1: + version "0.7.2" + resolved "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" + integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== -define-data-property@^1.1.4: - version "1.1.4" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/define-data-property/-/define-data-property-1.1.4.tgz" - integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== +debug@^4.4.0, debug@^4.4.3: + version "4.4.3" + resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== dependencies: - es-define-property "^1.0.0" - es-errors "^1.3.0" - gopd "^1.0.1" + ms "^2.1.3" -depd@2.0.0: +depd@^2.0.0, depd@~2.0.0: version "2.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/depd/-/depd-2.0.0.tgz" + resolved "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== -destroy@^1.2.0, destroy@1.2.0: - version "1.2.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/destroy/-/destroy-1.2.0.tgz" - integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== +dunder-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" + integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + dependencies: + call-bind-apply-helpers "^1.0.1" + es-errors "^1.3.0" + gopd "^1.2.0" ee-first@1.1.1: version "1.1.1" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ee-first/-/ee-first-1.1.1.tgz" + resolved "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== -encodeurl@^2.0.0, encodeurl@~2.0.0: +encodeurl@^2.0.0: version "2.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/encodeurl/-/encodeurl-2.0.0.tgz" + resolved "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== -encodeurl@~1.0.2: - version "1.0.2" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/encodeurl/-/encodeurl-1.0.2.tgz" - integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== - -es-define-property@^1.0.0: - version "1.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/es-define-property/-/es-define-property-1.0.0.tgz" - integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ== - dependencies: - get-intrinsic "^1.2.4" +es-define-property@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" + integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== es-errors@^1.3.0: version "1.3.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/es-errors/-/es-errors-1.3.0.tgz" + resolved "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== -escape-html@^1.0.3, escape-html@~1.0.3: +es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: + version "1.1.2" + resolved "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz#a2d0b373205724dfa525d23b0c3e1b1ca582c99b" + integrity sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw== + dependencies: + es-errors "^1.3.0" + +escape-html@^1.0.3: version "1.0.3" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/escape-html/-/escape-html-1.0.3.tgz" + resolved "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== -etag@^1.8.1, etag@~1.8.1: +etag@^1.8.1: version "1.8.1" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/etag/-/etag-1.8.1.tgz" + resolved "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== express@^5.0.0: - version "5.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/express/-/express-5.0.0.tgz" - integrity sha512-V4UkHQc+B7ldh1YC84HCXHwf60M4BOMvp9rkvTUWCK5apqDC1Esnbid4wm6nFyVuDy8XMfETsJw5lsIGBWyo0A== + version "5.2.1" + resolved "https://registry.npmjs.org/express/-/express-5.2.1.tgz#8f21d15b6d327f92b4794ecf8cb08a72f956ac04" + integrity sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw== dependencies: accepts "^2.0.0" - body-parser "^2.0.1" + body-parser "^2.2.1" content-disposition "^1.0.0" - content-type "~1.0.4" - cookie "0.6.0" + content-type "^1.0.5" + cookie "^0.7.1" cookie-signature "^1.2.1" - debug "4.3.6" - depd "2.0.0" - encodeurl "~2.0.0" - escape-html "~1.0.3" - etag "~1.8.1" - finalhandler "^2.0.0" - fresh "2.0.0" - http-errors "2.0.0" + debug "^4.4.0" + depd "^2.0.0" + encodeurl "^2.0.0" + escape-html "^1.0.3" + etag "^1.8.1" + finalhandler "^2.1.0" + fresh "^2.0.0" + http-errors "^2.0.0" merge-descriptors "^2.0.0" - methods "~1.1.2" mime-types "^3.0.0" - on-finished "2.4.1" - once "1.4.0" - parseurl "~1.3.3" - proxy-addr "~2.0.7" - qs "6.13.0" - range-parser "~1.2.1" - router "^2.0.0" - safe-buffer "5.2.1" + on-finished "^2.4.1" + once "^1.4.0" + parseurl "^1.3.3" + proxy-addr "^2.0.7" + qs "^6.14.0" + range-parser "^1.2.1" + router "^2.2.0" send "^1.1.0" - serve-static "^2.1.0" - setprototypeof "1.2.0" - statuses "2.0.1" - type-is "^2.0.0" - utils-merge "1.0.1" - vary "~1.1.2" - -finalhandler@^2.0.0: - version "2.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/finalhandler/-/finalhandler-2.0.0.tgz" - integrity sha512-MX6Zo2adDViYh+GcxxB1dpO43eypOGUOL12rLCOTMQv/DfIbpSJUy4oQIIZhVZkH9e+bZWKMon0XHFEju16tkQ== + serve-static "^2.2.0" + statuses "^2.0.1" + type-is "^2.0.1" + vary "^1.1.2" + +finalhandler@^2.1.0: + version "2.1.1" + resolved "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz#a2c517a6559852bcdb06d1f8bd7f51b68fad8099" + integrity sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA== dependencies: - debug "2.6.9" - encodeurl "~1.0.2" - escape-html "~1.0.3" - on-finished "2.4.1" - parseurl "~1.3.3" - statuses "2.0.1" - unpipe "~1.0.0" + debug "^4.4.0" + encodeurl "^2.0.0" + escape-html "^1.0.3" + on-finished "^2.4.1" + parseurl "^1.3.3" + statuses "^2.0.1" forwarded@0.2.0: version "0.2.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/forwarded/-/forwarded-0.2.0.tgz" + resolved "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== -fresh@^0.5.2: - version "0.5.2" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/fresh/-/fresh-0.5.2.tgz" - integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== - -fresh@2.0.0: +fresh@^2.0.0: version "2.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/fresh/-/fresh-2.0.0.tgz" + resolved "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz#8dd7df6a1b3a1b3a5cf186c05a5dd267622635a4" integrity sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A== function-bind@^1.1.2: version "1.1.2" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/function-bind/-/function-bind-1.1.2.tgz" + resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== -get-intrinsic@^1.1.3, get-intrinsic@^1.2.4: - version "1.2.4" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/get-intrinsic/-/get-intrinsic-1.2.4.tgz" - integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== +get-intrinsic@^1.2.5, get-intrinsic@^1.3.0: + version "1.3.0" + resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" + integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" es-errors "^1.3.0" + es-object-atoms "^1.1.1" function-bind "^1.1.2" - has-proto "^1.0.1" - has-symbols "^1.0.3" - hasown "^2.0.0" + get-proto "^1.0.1" + gopd "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + math-intrinsics "^1.1.0" -gopd@^1.0.1: +get-proto@^1.0.1: version "1.0.1" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/gopd/-/gopd-1.0.1.tgz" - integrity sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA== + resolved "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== dependencies: - get-intrinsic "^1.1.3" + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" -has-property-descriptors@^1.0.2: - version "1.0.2" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz" - integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== - dependencies: - es-define-property "^1.0.0" - -has-proto@^1.0.1: - version "1.0.3" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/has-proto/-/has-proto-1.0.3.tgz" - integrity sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q== +gopd@^1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== -has-symbols@^1.0.3: - version "1.0.3" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/has-symbols/-/has-symbols-1.0.3.tgz" - integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== +has-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== -hasown@^2.0.0: - version "2.0.2" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/hasown/-/hasown-2.0.2.tgz" - integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== +hasown@^2.0.2: + version "2.0.4" + resolved "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003" + integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A== dependencies: function-bind "^1.1.2" -http-errors@^2.0.0, http-errors@2.0.0: - version "2.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/http-errors/-/http-errors-2.0.0.tgz" - integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== - dependencies: - depd "2.0.0" - inherits "2.0.4" - setprototypeof "1.2.0" - statuses "2.0.1" - toidentifier "1.0.1" - -iconv-lite@0.5.2: - version "0.5.2" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/iconv-lite/-/iconv-lite-0.5.2.tgz" - integrity sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag== +http-errors@^2.0.0, http-errors@^2.0.1, http-errors@~2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz#36d2f65bc909c8790018dd36fb4d93da6caae06b" + integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ== dependencies: - safer-buffer ">= 2.1.2 < 3" + depd "~2.0.0" + inherits "~2.0.4" + setprototypeof "~1.2.0" + statuses "~2.0.2" + toidentifier "~1.0.1" -iconv-lite@0.6.3: - version "0.6.3" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/iconv-lite/-/iconv-lite-0.6.3.tgz" - integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== +iconv-lite@^0.7.2, iconv-lite@~0.7.0: + version "0.7.3" + resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz#84ee12f963e7de50bc01a13e160a078b3b0f415f" + integrity sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ== dependencies: safer-buffer ">= 2.1.2 < 3.0.0" -inherits@2.0.4: +inherits@~2.0.4: version "2.0.4" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/inherits/-/inherits-2.0.4.tgz" + resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== ipaddr.js@1.9.1: version "1.9.1" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ipaddr.js/-/ipaddr.js-1.9.1.tgz" + resolved "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== -is-promise@4.0.0: +is-promise@^4.0.0: version "4.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/is-promise/-/is-promise-4.0.0.tgz" + resolved "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz#42ff9f84206c1991d26debf520dd5c01042dd2f3" integrity sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ== +math-intrinsics@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" + integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== + media-typer@^1.1.0: version "1.1.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/media-typer/-/media-typer-1.1.0.tgz" + resolved "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz#6ab74b8f2d3320f2064b2a87a38e7931ff3a5561" integrity sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw== -media-typer@0.3.0: - version "0.3.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/media-typer/-/media-typer-0.3.0.tgz" - integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== - merge-descriptors@^2.0.0: version "2.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/merge-descriptors/-/merge-descriptors-2.0.0.tgz" + resolved "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz#ea922f660635a2249ee565e0449f951e6b603808" integrity sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g== -methods@~1.1.2: - version "1.1.2" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/methods/-/methods-1.1.2.tgz" - integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== - -mime-db@^1.53.0: - version "1.53.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-db/-/mime-db-1.53.0.tgz" - integrity sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg== - -mime-db@1.40.0: - version "1.40.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-db/-/mime-db-1.40.0.tgz" - integrity sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA== - -mime-db@1.52.0: - version "1.52.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-db/-/mime-db-1.52.0.tgz" - integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== - -mime-types@^2.1.35: - version "2.1.35" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-types/-/mime-types-2.1.35.tgz" - integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== - dependencies: - mime-db "1.52.0" - -mime-types@^3.0.0: - version "3.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-types/-/mime-types-3.0.0.tgz" - integrity sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w== - dependencies: - mime-db "^1.53.0" +mime-db@^1.54.0: + version "1.54.0" + resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz#cddb3ee4f9c64530dff640236661d42cb6a314f5" + integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== -mime-types@~2.1.24: - version "2.1.24" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-types/-/mime-types-2.1.24.tgz" - integrity sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ== +mime-types@^3.0.0, mime-types@^3.0.2: + version "3.0.2" + resolved "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz#39002d4182575d5af036ffa118100f2524b2e2ab" + integrity sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A== dependencies: - mime-db "1.40.0" + mime-db "^1.54.0" ms@^2.1.3: version "2.1.3" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ms/-/ms-2.1.3.tgz" + resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== -ms@2.0.0: - version "2.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ms/-/ms-2.0.0.tgz" - integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== - -ms@2.1.2: - version "2.1.2" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ms/-/ms-2.1.2.tgz" - integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== - negotiator@^1.0.0: version "1.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/negotiator/-/negotiator-1.0.0.tgz" + resolved "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz#b6c91bb47172d69f93cfd7c357bbb529019b5f6a" integrity sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg== -object-inspect@^1.13.1: - version "1.13.2" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/object-inspect/-/object-inspect-1.13.2.tgz" - integrity sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g== +object-inspect@^1.13.3, object-inspect@^1.13.4: + version "1.13.4" + resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" + integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== -on-finished@^2.4.1, on-finished@2.4.1: +on-finished@^2.4.1: version "2.4.1" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/on-finished/-/on-finished-2.4.1.tgz" + resolved "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== dependencies: ee-first "1.1.1" -once@1.4.0: +once@^1.4.0: version "1.4.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/once/-/once-1.4.0.tgz" + resolved "https://registry.npmjs.org/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== dependencies: wrappy "1" openai@^6.45.0: version "6.45.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/openai/-/openai-6.45.0.tgz" + resolved "https://registry.npmjs.org/openai/-/openai-6.45.0.tgz#53840c5c5848884dfbff47006f839b27d1d955b9" integrity sha512-5DQVNErssk0afNpTTHUm/qZPU4iKR9OYdNid8Ib4puq4gHNNvGWZht2zY4h9a8JMF949Ik6m8gQutllVPbjdnw== -parseurl@^1.3.3, parseurl@~1.3.3: +parseurl@^1.3.3: version "1.3.3" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/parseurl/-/parseurl-1.3.3.tgz" + resolved "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== path-to-regexp@^8.0.0: - version "8.1.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/path-to-regexp/-/path-to-regexp-8.1.0.tgz" - integrity sha512-Bqn3vc8CMHty6zuD+tG23s6v2kwxslHEhTj4eYaVKGIEB+YX/2wd0/rgXLFD9G9id9KCtbVy/3ZgmvZjpa0UdQ== + version "8.4.2" + resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz#795c420c4f7ca45c5b887366f622ee0c9852cccd" + integrity sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA== -proxy-addr@~2.0.7: +proxy-addr@^2.0.7: version "2.0.7" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/proxy-addr/-/proxy-addr-2.0.7.tgz" + resolved "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== dependencies: forwarded "0.2.0" ipaddr.js "1.9.1" -qs@6.13.0: - version "6.13.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/qs/-/qs-6.13.0.tgz" - integrity sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg== +qs@^6.14.0, qs@^6.15.2: + version "6.15.3" + resolved "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz#76852132a58ed5c7c0ef67e4441b9bb5d6061b3b" + integrity sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A== dependencies: - side-channel "^1.0.6" + es-define-property "^1.0.1" + side-channel "^1.1.1" -range-parser@^1.2.1, range-parser@~1.2.1: - version "1.2.1" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/range-parser/-/range-parser-1.2.1.tgz" - integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== +range-parser@^1.2.1: + version "1.3.0" + resolved "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz#d7f19be812bb62721472b45d3be219ef09572b47" + integrity sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw== -raw-body@^3.0.0: - version "3.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/raw-body/-/raw-body-3.0.0.tgz" - integrity sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g== +raw-body@^3.0.2: + version "3.0.2" + resolved "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz#3e3ada5ae5568f9095d84376fd3a49b8fb000a51" + integrity sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA== dependencies: - bytes "3.1.2" - http-errors "2.0.0" - iconv-lite "0.6.3" - unpipe "1.0.0" + bytes "~3.1.2" + http-errors "~2.0.1" + iconv-lite "~0.7.0" + unpipe "~1.0.0" -router@^2.0.0: - version "2.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/router/-/router-2.0.0.tgz" - integrity sha512-dIM5zVoG8xhC6rnSN8uoAgFARwTE7BQs8YwHEvK0VCmfxQXMaOuA1uiR1IPwsW7JyK5iTt7Od/TC9StasS2NPQ== +router@^2.2.0: + version "2.2.0" + resolved "https://registry.npmjs.org/router/-/router-2.2.0.tgz#019be620b711c87641167cc79b99090f00b146ef" + integrity sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ== dependencies: - array-flatten "3.0.0" - is-promise "4.0.0" - methods "~1.1.2" - parseurl "~1.3.3" + debug "^4.4.0" + depd "^2.0.0" + is-promise "^4.0.0" + parseurl "^1.3.3" path-to-regexp "^8.0.0" - setprototypeof "1.2.0" - utils-merge "1.0.1" -safe-buffer@5.2.1: - version "5.2.1" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/safe-buffer/-/safe-buffer-5.2.1.tgz" - integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== - -"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": +"safer-buffer@>= 2.1.2 < 3.0.0": version "2.1.2" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/safer-buffer/-/safer-buffer-2.1.2.tgz" + resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== -send@^1.0.0, send@^1.1.0: - version "1.1.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/send/-/send-1.1.0.tgz" - integrity sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA== +send@^1.1.0, send@^1.2.0: + version "1.2.1" + resolved "https://registry.npmjs.org/send/-/send-1.2.1.tgz#9eab743b874f3550f40a26867bf286ad60d3f3ed" + integrity sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ== dependencies: - debug "^4.3.5" - destroy "^1.2.0" + debug "^4.4.3" encodeurl "^2.0.0" escape-html "^1.0.3" etag "^1.8.1" - fresh "^0.5.2" - http-errors "^2.0.0" - mime-types "^2.1.35" + fresh "^2.0.0" + http-errors "^2.0.1" + mime-types "^3.0.2" ms "^2.1.3" on-finished "^2.4.1" range-parser "^1.2.1" - statuses "^2.0.1" + statuses "^2.0.2" -serve-static@^2.1.0: - version "2.1.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/serve-static/-/serve-static-2.1.0.tgz" - integrity sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA== +serve-static@^2.2.0: + version "2.2.1" + resolved "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz#7f186a4a4e5f5b663ad7a4294ff1bf37cf0e98a9" + integrity sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw== dependencies: encodeurl "^2.0.0" escape-html "^1.0.3" parseurl "^1.3.3" - send "^1.0.0" + send "^1.2.0" -set-function-length@^1.2.1: - version "1.2.2" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/set-function-length/-/set-function-length-1.2.2.tgz" - integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== +setprototypeof@~1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + +side-channel-list@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz#c2e0b5a14a540aebee3bbc6c3f8666cc9b509127" + integrity sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w== dependencies: - define-data-property "^1.1.4" es-errors "^1.3.0" - function-bind "^1.1.2" - get-intrinsic "^1.2.4" - gopd "^1.0.1" - has-property-descriptors "^1.0.2" + object-inspect "^1.13.4" -setprototypeof@1.2.0: - version "1.2.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/setprototypeof/-/setprototypeof-1.2.0.tgz" - integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== +side-channel-map@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" + integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" -side-channel@^1.0.6: - version "1.0.6" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/side-channel/-/side-channel-1.0.6.tgz" - integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA== +side-channel-weakmap@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" + integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== dependencies: - call-bind "^1.0.7" + call-bound "^1.0.2" es-errors "^1.3.0" - get-intrinsic "^1.2.4" - object-inspect "^1.13.1" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + side-channel-map "^1.0.1" -statuses@^2.0.1, statuses@2.0.1: - version "2.0.1" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/statuses/-/statuses-2.0.1.tgz" - integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== +side-channel@^1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz#ea02c62e05dc4bea67d4442f0fb71ee192f8e0ab" + integrity sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.4" + side-channel-list "^1.0.1" + side-channel-map "^1.0.1" + side-channel-weakmap "^1.0.2" + +statuses@^2.0.1, statuses@^2.0.2, statuses@~2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382" + integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== -toidentifier@1.0.1: +toidentifier@~1.0.1: version "1.0.1" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/toidentifier/-/toidentifier-1.0.1.tgz" + resolved "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== -type-is@^2.0.0: - version "2.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/type-is/-/type-is-2.0.0.tgz" - integrity sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw== +type-is@^2.0.1, type-is@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz#71d1a7053293582e16ac9f3ebaf1ab9aa49e5570" + integrity sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA== dependencies: - content-type "^1.0.5" + content-type "^2.0.0" media-typer "^1.1.0" mime-types "^3.0.0" -type-is@~1.6.18: - version "1.6.18" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/type-is/-/type-is-1.6.18.tgz" - integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== - dependencies: - media-typer "0.3.0" - mime-types "~2.1.24" - -unpipe@~1.0.0, unpipe@1.0.0: +unpipe@~1.0.0: version "1.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/unpipe/-/unpipe-1.0.0.tgz" + resolved "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== -utils-merge@1.0.1: - version "1.0.1" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/utils-merge/-/utils-merge-1.0.1.tgz" - integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== - -vary@~1.1.2: +vary@^1.1.2: version "1.1.2" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/vary/-/vary-1.1.2.tgz" + resolved "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== wrappy@1: version "1.0.2" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/wrappy/-/wrappy-1.0.2.tgz" + resolved "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== From 4fc86418f5f904e42fad9f96c75b338422ff67de Mon Sep 17 00:00:00 2001 From: Priyank Modi Date: Mon, 6 Jul 2026 19:05:52 +0530 Subject: [PATCH 06/35] fix: declare OPENAI_API_KEY in render.yaml envVars app.js reads process.env.OPENAI_API_KEY but the Blueprint never declared the key, so a value set in the Render dashboard was never wired into the service, causing "Missing credentials" at boot. --- render.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/render.yaml b/render.yaml index 5a4e5ac55d..f4b937f914 100644 --- a/render.yaml +++ b/render.yaml @@ -14,3 +14,5 @@ services: sync: false - key: WHATSAPP_TOKEN sync: false + - key: OPENAI_API_KEY + sync: false From f7ee32e1a6371b6fe2806236b7c6bde00eb21608 Mon Sep 17 00:00:00 2001 From: Priyank Modi Date: Mon, 6 Jul 2026 20:37:24 +0530 Subject: [PATCH 07/35] fix: make OpenAI model/baseURL configurable and log request/error details Model was hardcoded to gpt-4o while the configured Azure OpenAI resource only has a gpt-5.4-mini deployment, causing every request to fail with a 404 "deployment does not exist" error. Reads OPENAI_MODEL/OPENAI_BASE_URL from env instead, and logs the resolved config plus full error details on failure to make future mismatches easy to diagnose. Co-Authored-By: Claude Sonnet 5 --- app.js | 24 +++++++++++++++++++++--- render.yaml | 4 ++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/app.js b/app.js index 0eb789d47d..3f5ad89e3f 100644 --- a/app.js +++ b/app.js @@ -8,8 +8,10 @@ const port = process.env.PORT || 3000; const verifyToken = process.env.VERIFY_TOKEN; const whatsappPhoneNumberId = process.env.WHATSAPP_PHONE_NUMBER_ID; const whatsappToken = process.env.WHATSAPP_TOKEN; +const openaiBaseURL = process.env.OPENAI_BASE_URL || undefined; +const openaiModel = process.env.OPENAI_MODEL || 'gpt-4o'; -const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); +const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY, baseURL: openaiBaseURL }); // In-memory conversation history per phone number (last 20 messages kept) const conversationHistory = new Map(); @@ -157,8 +159,12 @@ If the request is ambiguous or missing details, use ask_for_more_information.`, { role: 'user', content: userMessage }, ]; + console.log( + `[decideAction] calling chat.completions.create — model: ${openaiModel}, baseURL: ${openai.baseURL}, phone: ${phoneNumber}` + ); + const response = await openai.chat.completions.create({ - model: 'gpt-4o', + model: openaiModel, messages, tools, tool_choice: 'required', @@ -242,10 +248,22 @@ app.post('/', async (req, res) => { appendHistory(phoneNumber, 'assistant', replyText); } } catch (err) { - console.error('Error handling message:', err.message); + console.error('Error handling message:', { + message: err.message, + status: err.status, + code: err.code, + type: err.type, + requestID: err.requestID, + error: err.error, + model: openaiModel, + baseURL: openai.baseURL, + }); } }); app.listen(port, () => { console.log(`\nListening on port ${port}\n`); + console.log(`[startup] OPENAI_BASE_URL: ${process.env.OPENAI_BASE_URL ?? '(unset, defaults to api.openai.com)'}`); + console.log(`[startup] OPENAI_MODEL: ${openaiModel}`); + console.log(`[startup] OPENAI_API_KEY set: ${Boolean(process.env.OPENAI_API_KEY)}`); }); diff --git a/render.yaml b/render.yaml index f4b937f914..55fe1154ed 100644 --- a/render.yaml +++ b/render.yaml @@ -16,3 +16,7 @@ services: sync: false - key: OPENAI_API_KEY sync: false + - key: OPENAI_BASE_URL + sync: false + - key: OPENAI_MODEL + sync: false From 272e14ae2fb920eb771d26c0641be77bb72f267f Mon Sep 17 00:00:00 2001 From: priyankmodiPM <32540484+priyankmodiPM@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:51:28 +0530 Subject: [PATCH 08/35] Feat/express edit flow (#4) * docs: add design spec for mocked Adobe Express image-edit flow * docs: add implementation plan for image-edit flow * feat: add in-memory tracked-image store * feat: add mock Adobe Express API module * feat: add mock Meta media upload module * feat: extract GPT action handlers and add image-edit validation flow * feat: wire image-edit flow into the webhook and GPT tool schema * feat: map Croma earbuds graphic to Price/Address/Product Image/Partner Logo edits Replaces the New Arrival Poster mock seed image with the real Croma earbuds graphic and its allowed-edit fields. * feat: return fixed updated Croma earbuds image for any edit Any allowed edit on the Croma earbuds graphic now resolves to the fixed croma1-earbuds-updated image, sent as a new WhatsApp message, instead of a randomized mock render/upload URL. --------- Co-authored-by: Priyank Modi --- actions.js | 61 ++ actions.test.js | 66 ++ app.js | 58 +- .../plans/2026-07-13-express-edit-flow.md | 705 ++++++++++++++++++ .../2026-07-13-express-edit-flow-design.md | 132 ++++ expressApi.js | 33 + expressApi.test.js | 39 + imageStore.js | 28 + imageStore.test.js | 35 + metaUpload.js | 10 + metaUpload.test.js | 19 + package.json | 3 +- 12 files changed, 1158 insertions(+), 31 deletions(-) create mode 100644 actions.js create mode 100644 actions.test.js create mode 100644 docs/superpowers/plans/2026-07-13-express-edit-flow.md create mode 100644 docs/superpowers/specs/2026-07-13-express-edit-flow-design.md create mode 100644 expressApi.js create mode 100644 expressApi.test.js create mode 100644 imageStore.js create mode 100644 imageStore.test.js create mode 100644 metaUpload.js create mode 100644 metaUpload.test.js diff --git a/actions.js b/actions.js new file mode 100644 index 0000000000..a990e7b913 --- /dev/null +++ b/actions.js @@ -0,0 +1,61 @@ +const { getTrackedImages, findTrackedImage } = require('./imageStore'); +const { getTemplateInfo, applyEdit } = require('./expressApi'); +const { uploadImageToMeta } = require('./metaUpload'); + +function formatUnknownImageMessage(phoneNumber) { + const images = getTrackedImages(phoneNumber); + const list = images.map((image) => `- ${image.name}`).join('\n'); + return `I couldn't find that image. Here's what I have:\n${list}`; +} + +async function actionListCampaignGraphics() { + // TODO: fetch from campaign API + return 'Graphics in your current campaign:\n1. Diwali Offer Banner\n2. Summer Sale Flyer\n3. Croma Earbuds'; +} + +async function actionCheckAllowedEdits(phoneNumber, imageId) { + const image = findTrackedImage(phoneNumber, imageId); + if (!image) { + return formatUnknownImageMessage(phoneNumber); + } + const { unlockedLayers } = getTemplateInfo(image.templateId); + const layerList = unlockedLayers.map((layer) => `- ${layer}`).join('\n'); + return `Edits allowed on "${image.name}":\n${layerList}`; +} + +async function actionEditGraphic(phoneNumber, imageId, edits, { sendImage }) { + const image = findTrackedImage(phoneNumber, imageId); + if (!image) { + return formatUnknownImageMessage(phoneNumber); + } + + const { unlockedLayers } = getTemplateInfo(image.templateId); + const requestedKeys = Object.keys(edits || {}); + const disallowedKeys = requestedKeys.filter((key) => !unlockedLayers.includes(key)); + + if (disallowedKeys.length > 0) { + const allowedList = unlockedLayers.map((layer) => `- ${layer}`).join('\n'); + return `I can't edit ${disallowedKeys.join(', ')} on "${image.name}". Allowed edits:\n${allowedList}`; + } + + const { mergedEdits, renderedImageUrl } = applyEdit(image.templateId, image.currentEdits, edits); + image.currentEdits = mergedEdits; + + const uploadedUrl = await uploadImageToMeta(renderedImageUrl); + await sendImage(phoneNumber, uploadedUrl); + + const summary = Object.entries(edits).map(([key, value]) => `• ${key}: ${value}`).join('\n'); + return `Updated "${image.name}":\n${summary}`; +} + +async function actionGenerateBulkGraphics(filename) { + // TODO: parse CSV/Excel and call Adobe Express API per row + return `Bulk generation complete! Graphics created from ${filename || 'your uploaded file'}.`; +} + +module.exports = { + actionListCampaignGraphics, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, +}; diff --git a/actions.test.js b/actions.test.js new file mode 100644 index 0000000000..fa88280a39 --- /dev/null +++ b/actions.test.js @@ -0,0 +1,66 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { actionCheckAllowedEdits, actionEditGraphic } = require('./actions'); +const { getTrackedImages } = require('./imageStore'); + +test('actionCheckAllowedEdits lists the unlocked layers for a known image', async () => { + const reply = await actionCheckAllowedEdits('phone-1', 'img_1'); + assert.match(reply, /Diwali Offer Banner/); + assert.match(reply, /discount_text/); +}); + +test('actionCheckAllowedEdits lists Price, Address, Product Image, Partner Logo for the Croma earbuds image', async () => { + const reply = await actionCheckAllowedEdits('phone-croma', 'img_3'); + assert.match(reply, /Croma Earbuds/); + assert.match(reply, /- Price/); + assert.match(reply, /- Address/); + assert.match(reply, /- Product Image/); + assert.match(reply, /- Partner Logo/); +}); + +test('actionCheckAllowedEdits reports unknown images without throwing', async () => { + const reply = await actionCheckAllowedEdits('phone-2', 'img_nope'); + assert.match(reply, /couldn't find that image/); +}); + +test('actionEditGraphic rejects edits outside the unlocked layers and sends nothing', async () => { + let sendImageCalled = false; + const sendImage = async () => { + sendImageCalled = true; + }; + + const reply = await actionEditGraphic('phone-3', 'img_3', { background_color: 'red' }, { sendImage }); + + assert.match(reply, /can't edit background_color/); + assert.equal(sendImageCalled, false); +}); + +test('actionEditGraphic sends the fixed updated Croma earbuds image for any allowed edit', async () => { + const sentCalls = []; + const sendImage = async (to, link) => { + sentCalls.push({ to, link }); + }; + + const reply = await actionEditGraphic('phone-5', 'img_3', { Price: '999' }, { sendImage }); + + assert.match(reply, /Updated "Croma Earbuds"/); + assert.equal(sentCalls.length, 1); + assert.equal(sentCalls[0].link, 'https://s7ap1.scene7.com/is/image/varun/croma1-earbuds-updated'); +}); + +test('actionEditGraphic applies an allowed edit, sends the updated image, and remembers the edit', async () => { + const sentCalls = []; + const sendImage = async (to, link) => { + sentCalls.push({ to, link }); + }; + + const reply = await actionEditGraphic('phone-4', 'img_2', { headline: 'Flash Sale' }, { sendImage }); + + assert.match(reply, /Updated "Summer Sale Flyer"/); + assert.equal(sentCalls.length, 1); + assert.equal(sentCalls[0].to, 'phone-4'); + assert.match(sentCalls[0].link, /^https:\/\/mock-meta-cdn\.local\//); + + const image = getTrackedImages('phone-4').find((img) => img.id === 'img_2'); + assert.equal(image.currentEdits.headline, 'Flash Sale'); +}); diff --git a/app.js b/app.js index 3f5ad89e3f..1d703d50e1 100644 --- a/app.js +++ b/app.js @@ -1,5 +1,12 @@ const express = require('express'); const OpenAI = require('openai'); +const { getTrackedImages } = require('./imageStore'); +const { + actionListCampaignGraphics, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, +} = require('./actions'); const app = express(); app.use(express.json()); @@ -83,24 +90,33 @@ const tools = [ type: 'function', function: { name: 'check_allowed_edits', - description: 'Check what edits are permitted on the current graphic', - parameters: { type: 'object', properties: {} }, + description: + 'Check what edits are permitted on a specific graphic. Pick image_id from the "Images previously sent to this user" list in the system prompt that best matches what the user is referring to.', + parameters: { + type: 'object', + properties: { + image_id: { type: 'string', description: 'The id of the image the user is asking about, from the tracked images list' }, + }, + required: ['image_id'], + }, }, }, { type: 'function', function: { name: 'edit_graphic', - description: 'Edit the current graphic via Adobe Express API (e.g. change discount text, colors)', + description: + 'Edit a specific graphic via Adobe Express API (e.g. change discount text, colors). Pick image_id from the "Images previously sent to this user" list in the system prompt that best matches what the user is referring to.', parameters: { type: 'object', properties: { + image_id: { type: 'string', description: 'The id of the image to edit, from the tracked images list' }, edits: { type: 'object', description: 'Key-value pairs of edits to apply, e.g. { "discount_text": "70%" }', }, }, - required: ['edits'], + required: ['image_id', 'edits'], }, }, }, @@ -119,33 +135,12 @@ const tools = [ }, ]; -// ── Action handlers (stubs — wire real APIs here) ──────────────────────────── - -async function actionListCampaignGraphics() { - // TODO: fetch from campaign API - return 'Graphics in your current campaign:\n1. Diwali Offer Banner\n2. Summer Sale Flyer\n3. New Arrival Poster'; -} - -async function actionCheckAllowedEdits() { - // TODO: fetch from Adobe Express API - return 'Edits allowed on the current graphic:\n- Discount percentage\n- Headline text\n- Background color\n- Font color'; -} - -async function actionEditGraphic(edits) { - // TODO: call Adobe Express API - const summary = Object.entries(edits).map(([k, v]) => `• ${k}: ${v}`).join('\n'); - return `Graphic updated successfully:\n${summary}`; -} - -async function actionGenerateBulkGraphics(filename) { - // TODO: parse CSV/Excel and call Adobe Express API per row - return `Bulk generation complete! Graphics created from ${filename || 'your uploaded file'}.`; -} - // ── GPT decision engine ────────────────────────────────────────────────────── async function decideAction(phoneNumber, userMessage) { const last3 = getHistory(phoneNumber).slice(-3); + const trackedImages = getTrackedImages(phoneNumber); + const imagesList = trackedImages.map((image) => `- ${image.id}: ${image.name}`).join('\n'); const messages = [ { @@ -153,7 +148,10 @@ async function decideAction(phoneNumber, userMessage) { content: `You are a WhatsApp assistant for managing marketing campaign graphics via Adobe Express. Analyze the user's message and conversation history, then call the appropriate tool. Always call exactly one tool — never reply with plain text. -If the request is ambiguous or missing details, use ask_for_more_information.`, +If the request is ambiguous or missing details, use ask_for_more_information. + +Images previously sent to this user (reference by id): +${imagesList}`, }, ...last3, { role: 'user', content: userMessage }, @@ -227,12 +225,12 @@ app.post('/', async (req, res) => { case 'check_allowed_edits': await sendText(phoneNumber, '⏳ Checking allowed edits...'); - replyText = await actionCheckAllowedEdits(); + replyText = await actionCheckAllowedEdits(phoneNumber, args.image_id); break; case 'edit_graphic': await sendText(phoneNumber, '⏳ Applying edits to your graphic...'); - replyText = await actionEditGraphic(args.edits); + replyText = await actionEditGraphic(phoneNumber, args.image_id, args.edits, { sendImage }); break; case 'generate_bulk_graphics': diff --git a/docs/superpowers/plans/2026-07-13-express-edit-flow.md b/docs/superpowers/plans/2026-07-13-express-edit-flow.md new file mode 100644 index 0000000000..b36d079cae --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-express-edit-flow.md @@ -0,0 +1,705 @@ +# Express Image Edit Flow Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a WhatsApp user ask what edits are allowed on a previously-sent graphic, request an edit, have it validated against the graphic's unlocked layers, applied via a mocked Adobe Express API, uploaded via a mocked Meta upload, and re-sent as an updated image — all with placeholder Express/Meta calls that can be swapped for real ones later. + +**Architecture:** Three new small, dependency-free modules (`imageStore.js` for per-phone-number tracked-image state, `expressApi.js` for mock template/edit calls, `metaUpload.js` for the mock media upload) get unit tests via Node's built-in test runner. A fourth new module, `actions.js`, extracts all GPT tool-call action handlers (including the two existing ones, `list_campaign_graphics` and `generate_bulk_graphics`, moved unchanged) so the new `edit_graphic` logic can be unit-tested with a fake `sendImage` — no real WhatsApp/OpenAI credentials needed for tests. `app.js` is left as the thin orchestrator: WhatsApp API calls, GPT tool schemas/system prompt, and the webhook route wiring args into `actions.js`. + +**Tech Stack:** Node.js 20, Express 5, `openai` SDK, Node's built-in `node:test` + `node:assert/strict` (no new dependencies). + +## Global Constraints + +- No real Adobe Express or Meta API calls — all such calls are placeholder/mock functions returning mock values (per spec, these get swapped for real implementations later). +- Actually sending the initial graphic image is out of scope — tracked images are pre-seeded in memory at first access per phone number (spec: 3 fixed seed entries: Diwali Offer Banner / Summer Sale Flyer / New Arrival Poster). +- State is in-memory only, no persistence across restarts (matches existing `conversationHistory` pattern in app.js). +- No new npm dependencies — use Node's built-in `node:test` test runner. +- Follow existing code style: CommonJS `require`, 2-space indentation, semicolons. + +--- + +## Task 1: `imageStore.js` — tracked-image state + +**Files:** +- Create: `imageStore.js` +- Test: `imageStore.test.js` +- Modify: `package.json` (add a `test` script) + +**Interfaces:** +- Produces: `getTrackedImages(phoneNumber) -> Array<{ id: string, name: string, templateId: string, currentEdits: object }>` (seeds 3 fixed entries on first call for a phone number, returns the same array reference on later calls). +- Produces: `findTrackedImage(phoneNumber, imageId) -> object | undefined`. + +- [ ] **Step 1: Write the failing test** + +Create `imageStore.test.js`: + +```js +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { getTrackedImages, findTrackedImage } = require('./imageStore'); + +test('getTrackedImages seeds 3 images on first access', () => { + const images = getTrackedImages('111'); + assert.equal(images.length, 3); + assert.deepEqual(images.map((img) => img.id), ['img_1', 'img_2', 'img_3']); + assert.deepEqual(images[0].currentEdits, {}); +}); + +test('getTrackedImages returns the same array on repeated calls for the same phone number', () => { + const first = getTrackedImages('222'); + first[0].currentEdits.headline = 'Flash Sale'; + const second = getTrackedImages('222'); + assert.equal(second[0].currentEdits.headline, 'Flash Sale'); +}); + +test('getTrackedImages seeds independently per phone number', () => { + getTrackedImages('333')[0].currentEdits.headline = 'Only for 333'; + const other = getTrackedImages('444'); + assert.deepEqual(other[0].currentEdits, {}); +}); + +test('findTrackedImage returns the matching image', () => { + getTrackedImages('555'); + const image = findTrackedImage('555', 'img_2'); + assert.equal(image.name, 'Summer Sale Flyer'); +}); + +test('findTrackedImage returns undefined for an unknown id', () => { + getTrackedImages('666'); + const image = findTrackedImage('666', 'img_999'); + assert.equal(image, undefined); +}); +``` + +- [ ] **Step 2: Add the test script and run to verify failure** + +Modify `package.json` scripts section to: + +```json +"scripts": { + "start": "node app.js", + "test": "node --test" +}, +``` + +Run: `node --test imageStore.test.js` +Expected: FAIL — `Cannot find module './imageStore'` + +- [ ] **Step 3: Write the implementation** + +Create `imageStore.js`: + +```js +const SEED_IMAGES = [ + { id: 'img_1', name: 'Diwali Offer Banner', templateId: 'tpl_diwali' }, + { id: 'img_2', name: 'Summer Sale Flyer', templateId: 'tpl_summer' }, + { id: 'img_3', name: 'New Arrival Poster', templateId: 'tpl_newarrival' }, +]; + +const trackedImages = new Map(); + +function getTrackedImages(phoneNumber) { + if (!trackedImages.has(phoneNumber)) { + trackedImages.set( + phoneNumber, + SEED_IMAGES.map((image) => ({ ...image, currentEdits: {} })) + ); + } + return trackedImages.get(phoneNumber); +} + +function findTrackedImage(phoneNumber, imageId) { + return getTrackedImages(phoneNumber).find((image) => image.id === imageId); +} + +module.exports = { getTrackedImages, findTrackedImage }; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --test imageStore.test.js` +Expected: PASS — 5 tests passing + +- [ ] **Step 5: Commit** + +```bash +git add imageStore.js imageStore.test.js package.json +git commit -m "feat: add in-memory tracked-image store" +``` + +--- + +## Task 2: `expressApi.js` — mock Adobe Express calls + +**Files:** +- Create: `expressApi.js` +- Test: `expressApi.test.js` + +**Interfaces:** +- Consumes: nothing (standalone module). +- Produces: `getTemplateInfo(templateId) -> { templateId: string, unlockedLayers: string[] }`. +- Produces: `applyEdit(templateId, currentEdits, newEdits) -> { mergedEdits: object, renderedImageUrl: string }`. + +- [ ] **Step 1: Write the failing test** + +Create `expressApi.test.js`: + +```js +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { getTemplateInfo, applyEdit } = require('./expressApi'); + +test('getTemplateInfo returns the unlocked layers for a known template', () => { + const info = getTemplateInfo('tpl_diwali'); + assert.deepEqual(info, { + templateId: 'tpl_diwali', + unlockedLayers: ['discount_text', 'headline', 'background_color'], + }); +}); + +test('getTemplateInfo returns an empty layer list for an unknown template', () => { + const info = getTemplateInfo('tpl_does_not_exist'); + assert.deepEqual(info.unlockedLayers, []); +}); + +test('applyEdit merges new edits on top of current edits', () => { + const result = applyEdit('tpl_diwali', { headline: 'Old Headline' }, { discount_text: '70%' }); + assert.deepEqual(result.mergedEdits, { headline: 'Old Headline', discount_text: '70%' }); +}); + +test('applyEdit returns a rendered image url that references the template', () => { + const result = applyEdit('tpl_summer', {}, { headline: 'Flash Sale' }); + assert.match(result.renderedImageUrl, /^https:\/\/mock-express\.local\/render\/tpl_summer\?rev=\d+$/); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test expressApi.test.js` +Expected: FAIL — `Cannot find module './expressApi'` + +- [ ] **Step 3: Write the implementation** + +Create `expressApi.js`: + +```js +const TEMPLATE_LAYERS = { + tpl_diwali: ['discount_text', 'headline', 'background_color'], + tpl_summer: ['headline', 'font_color'], + tpl_newarrival: ['headline'], +}; + +function getTemplateInfo(templateId) { + return { + templateId, + unlockedLayers: TEMPLATE_LAYERS[templateId] || [], + }; +} + +let renderRevision = 0; + +function applyEdit(templateId, currentEdits, newEdits) { + const mergedEdits = { ...currentEdits, ...newEdits }; + renderRevision += 1; + return { + mergedEdits, + renderedImageUrl: `https://mock-express.local/render/${templateId}?rev=${renderRevision}`, + }; +} + +module.exports = { getTemplateInfo, applyEdit }; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --test expressApi.test.js` +Expected: PASS — 4 tests passing + +- [ ] **Step 5: Commit** + +```bash +git add expressApi.js expressApi.test.js +git commit -m "feat: add mock Adobe Express API module" +``` + +--- + +## Task 3: `metaUpload.js` — mock Meta media upload + +**Files:** +- Create: `metaUpload.js` +- Test: `metaUpload.test.js` + +**Interfaces:** +- Consumes: nothing (standalone module). +- Produces: `uploadImageToMeta(renderedImageUrl) -> Promise` (mock CDN URL). + +- [ ] **Step 1: Write the failing test** + +Create `metaUpload.test.js`: + +```js +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { uploadImageToMeta } = require('./metaUpload'); + +test('uploadImageToMeta returns a mock CDN url', async () => { + const url = await uploadImageToMeta('https://mock-express.local/render/tpl_diwali?rev=1'); + assert.match(url, /^https:\/\/mock-meta-cdn\.local\/media\/[0-9a-f-]+\.png$/); +}); + +test('uploadImageToMeta returns a different url on each call', async () => { + const first = await uploadImageToMeta('https://mock-express.local/render/tpl_diwali?rev=1'); + const second = await uploadImageToMeta('https://mock-express.local/render/tpl_diwali?rev=2'); + assert.notEqual(first, second); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test metaUpload.test.js` +Expected: FAIL — `Cannot find module './metaUpload'` + +- [ ] **Step 3: Write the implementation** + +Create `metaUpload.js`: + +```js +const { randomUUID } = require('node:crypto'); + +async function uploadImageToMeta(renderedImageUrl) { + return `https://mock-meta-cdn.local/media/${randomUUID()}.png`; +} + +module.exports = { uploadImageToMeta }; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --test metaUpload.test.js` +Expected: PASS — 2 tests passing + +- [ ] **Step 5: Commit** + +```bash +git add metaUpload.js metaUpload.test.js +git commit -m "feat: add mock Meta media upload module" +``` + +--- + +## Task 4: `actions.js` — extract and extend GPT tool-call action handlers + +**Files:** +- Create: `actions.js` +- Test: `actions.test.js` + +**Interfaces:** +- Consumes: `getTrackedImages`, `findTrackedImage` from `./imageStore` (Task 1); `getTemplateInfo`, `applyEdit` from `./expressApi` (Task 2); `uploadImageToMeta` from `./metaUpload` (Task 3). +- Produces: `actionListCampaignGraphics() -> Promise` (unchanged behavior, moved from app.js). +- Produces: `actionCheckAllowedEdits(phoneNumber, imageId) -> Promise`. +- Produces: `actionEditGraphic(phoneNumber, imageId, edits, { sendImage }) -> Promise` — `sendImage` is injected `(to, link) => Promise` so tests don't need real WhatsApp calls. +- Produces: `actionGenerateBulkGraphics(filename) -> Promise` (unchanged behavior, moved from app.js). + +- [ ] **Step 1: Write the failing test** + +Create `actions.test.js`: + +```js +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { actionCheckAllowedEdits, actionEditGraphic } = require('./actions'); +const { getTrackedImages } = require('./imageStore'); + +test('actionCheckAllowedEdits lists the unlocked layers for a known image', async () => { + const reply = await actionCheckAllowedEdits('phone-1', 'img_1'); + assert.match(reply, /Diwali Offer Banner/); + assert.match(reply, /discount_text/); +}); + +test('actionCheckAllowedEdits reports unknown images without throwing', async () => { + const reply = await actionCheckAllowedEdits('phone-2', 'img_nope'); + assert.match(reply, /couldn't find that image/); +}); + +test('actionEditGraphic rejects edits outside the unlocked layers and sends nothing', async () => { + let sendImageCalled = false; + const sendImage = async () => { + sendImageCalled = true; + }; + + const reply = await actionEditGraphic('phone-3', 'img_3', { background_color: 'red' }, { sendImage }); + + assert.match(reply, /can't edit background_color/); + assert.equal(sendImageCalled, false); +}); + +test('actionEditGraphic applies an allowed edit, sends the updated image, and remembers the edit', async () => { + const sentCalls = []; + const sendImage = async (to, link) => { + sentCalls.push({ to, link }); + }; + + const reply = await actionEditGraphic('phone-4', 'img_2', { headline: 'Flash Sale' }, { sendImage }); + + assert.match(reply, /Updated "Summer Sale Flyer"/); + assert.equal(sentCalls.length, 1); + assert.equal(sentCalls[0].to, 'phone-4'); + assert.match(sentCalls[0].link, /^https:\/\/mock-meta-cdn\.local\//); + + const image = getTrackedImages('phone-4').find((img) => img.id === 'img_2'); + assert.equal(image.currentEdits.headline, 'Flash Sale'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test actions.test.js` +Expected: FAIL — `Cannot find module './actions'` + +- [ ] **Step 3: Write the implementation** + +Create `actions.js`: + +```js +const { getTrackedImages, findTrackedImage } = require('./imageStore'); +const { getTemplateInfo, applyEdit } = require('./expressApi'); +const { uploadImageToMeta } = require('./metaUpload'); + +function formatUnknownImageMessage(phoneNumber) { + const images = getTrackedImages(phoneNumber); + const list = images.map((image) => `- ${image.name}`).join('\n'); + return `I couldn't find that image. Here's what I have:\n${list}`; +} + +async function actionListCampaignGraphics() { + // TODO: fetch from campaign API + return 'Graphics in your current campaign:\n1. Diwali Offer Banner\n2. Summer Sale Flyer\n3. New Arrival Poster'; +} + +async function actionCheckAllowedEdits(phoneNumber, imageId) { + const image = findTrackedImage(phoneNumber, imageId); + if (!image) { + return formatUnknownImageMessage(phoneNumber); + } + const { unlockedLayers } = getTemplateInfo(image.templateId); + const layerList = unlockedLayers.map((layer) => `- ${layer}`).join('\n'); + return `Edits allowed on "${image.name}":\n${layerList}`; +} + +async function actionEditGraphic(phoneNumber, imageId, edits, { sendImage }) { + const image = findTrackedImage(phoneNumber, imageId); + if (!image) { + return formatUnknownImageMessage(phoneNumber); + } + + const { unlockedLayers } = getTemplateInfo(image.templateId); + const requestedKeys = Object.keys(edits || {}); + const disallowedKeys = requestedKeys.filter((key) => !unlockedLayers.includes(key)); + + if (disallowedKeys.length > 0) { + const allowedList = unlockedLayers.map((layer) => `- ${layer}`).join('\n'); + return `I can't edit ${disallowedKeys.join(', ')} on "${image.name}". Allowed edits:\n${allowedList}`; + } + + const { mergedEdits, renderedImageUrl } = applyEdit(image.templateId, image.currentEdits, edits); + image.currentEdits = mergedEdits; + + const uploadedUrl = await uploadImageToMeta(renderedImageUrl); + await sendImage(phoneNumber, uploadedUrl); + + const summary = Object.entries(edits).map(([key, value]) => `• ${key}: ${value}`).join('\n'); + return `Updated "${image.name}":\n${summary}`; +} + +async function actionGenerateBulkGraphics(filename) { + // TODO: parse CSV/Excel and call Adobe Express API per row + return `Bulk generation complete! Graphics created from ${filename || 'your uploaded file'}.`; +} + +module.exports = { + actionListCampaignGraphics, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, +}; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --test actions.test.js` +Expected: PASS — 4 tests passing + +- [ ] **Step 5: Run the full test suite** + +Run: `node --test` +Expected: PASS — all tests across `imageStore.test.js`, `expressApi.test.js`, `metaUpload.test.js`, `actions.test.js` (15 tests total) + +- [ ] **Step 6: Commit** + +```bash +git add actions.js actions.test.js +git commit -m "feat: extract GPT action handlers and add image-edit validation flow" +``` + +--- + +## Task 5: Wire `app.js` to the new modules + +**Files:** +- Modify: `app.js:1-2` (imports) +- Modify: `app.js:59-120` (tool definitions) +- Modify: `app.js:122-143` (delete — action handlers now live in `actions.js`) +- Modify: `app.js:147-174` (`decideAction` system prompt) +- Modify: `app.js:218-245` (webhook switch statement) + +**Interfaces:** +- Consumes: `getTrackedImages` from `./imageStore` (Task 1); `actionListCampaignGraphics`, `actionCheckAllowedEdits`, `actionEditGraphic`, `actionGenerateBulkGraphics` from `./actions` (Task 4). +- Produces: nothing new — this task is pure wiring, verified manually (no unit test; this is the orchestration layer that touches the real WhatsApp/OpenAI SDKs already excluded from automated testing per the spec). + +- [ ] **Step 1: Update imports** + +At the top of `app.js`, replace: + +```js +const express = require('express'); +const OpenAI = require('openai'); +``` + +with: + +```js +const express = require('express'); +const OpenAI = require('openai'); +const { getTrackedImages } = require('./imageStore'); +const { + actionListCampaignGraphics, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, +} = require('./actions'); +``` + +- [ ] **Step 2: Add `image_id` to the `check_allowed_edits` and `edit_graphic` tool definitions** + +In the `tools` array, replace the `check_allowed_edits` entry: + +```js + { + type: 'function', + function: { + name: 'check_allowed_edits', + description: 'Check what edits are permitted on the current graphic', + parameters: { type: 'object', properties: {} }, + }, + }, +``` + +with: + +```js + { + type: 'function', + function: { + name: 'check_allowed_edits', + description: + 'Check what edits are permitted on a specific graphic. Pick image_id from the "Images previously sent to this user" list in the system prompt that best matches what the user is referring to.', + parameters: { + type: 'object', + properties: { + image_id: { type: 'string', description: 'The id of the image the user is asking about, from the tracked images list' }, + }, + required: ['image_id'], + }, + }, + }, +``` + +And replace the `edit_graphic` entry: + +```js + { + type: 'function', + function: { + name: 'edit_graphic', + description: 'Edit the current graphic via Adobe Express API (e.g. change discount text, colors)', + parameters: { + type: 'object', + properties: { + edits: { + type: 'object', + description: 'Key-value pairs of edits to apply, e.g. { "discount_text": "70%" }', + }, + }, + required: ['edits'], + }, + }, + }, +``` + +with: + +```js + { + type: 'function', + function: { + name: 'edit_graphic', + description: + 'Edit a specific graphic via Adobe Express API (e.g. change discount text, colors). Pick image_id from the "Images previously sent to this user" list in the system prompt that best matches what the user is referring to.', + parameters: { + type: 'object', + properties: { + image_id: { type: 'string', description: 'The id of the image to edit, from the tracked images list' }, + edits: { + type: 'object', + description: 'Key-value pairs of edits to apply, e.g. { "discount_text": "70%" }', + }, + }, + required: ['image_id', 'edits'], + }, + }, + }, +``` + +- [ ] **Step 3: Delete the old inline action handlers** + +Delete this entire block (now provided by `actions.js`): + +```js +// ── Action handlers (stubs — wire real APIs here) ──────────────────────────── + +async function actionListCampaignGraphics() { + // TODO: fetch from campaign API + return 'Graphics in your current campaign:\n1. Diwali Offer Banner\n2. Summer Sale Flyer\n3. New Arrival Poster'; +} + +async function actionCheckAllowedEdits() { + // TODO: fetch from Adobe Express API + return 'Edits allowed on the current graphic:\n- Discount percentage\n- Headline text\n- Background color\n- Font color'; +} + +async function actionEditGraphic(edits) { + // TODO: call Adobe Express API + const summary = Object.entries(edits).map(([k, v]) => `• ${k}: ${v}`).join('\n'); + return `Graphic updated successfully:\n${summary}`; +} + +async function actionGenerateBulkGraphics(filename) { + // TODO: parse CSV/Excel and call Adobe Express API per row + return `Bulk generation complete! Graphics created from ${filename || 'your uploaded file'}.`; +} +``` + +- [ ] **Step 4: Add the tracked-images list to the `decideAction` system prompt** + +Replace: + +```js +async function decideAction(phoneNumber, userMessage) { + const last3 = getHistory(phoneNumber).slice(-3); + + const messages = [ + { + role: 'system', + content: `You are a WhatsApp assistant for managing marketing campaign graphics via Adobe Express. +Analyze the user's message and conversation history, then call the appropriate tool. +Always call exactly one tool — never reply with plain text. +If the request is ambiguous or missing details, use ask_for_more_information.`, + }, + ...last3, + { role: 'user', content: userMessage }, + ]; +``` + +with: + +```js +async function decideAction(phoneNumber, userMessage) { + const last3 = getHistory(phoneNumber).slice(-3); + const trackedImages = getTrackedImages(phoneNumber); + const imagesList = trackedImages.map((image) => `- ${image.id}: ${image.name}`).join('\n'); + + const messages = [ + { + role: 'system', + content: `You are a WhatsApp assistant for managing marketing campaign graphics via Adobe Express. +Analyze the user's message and conversation history, then call the appropriate tool. +Always call exactly one tool — never reply with plain text. +If the request is ambiguous or missing details, use ask_for_more_information. + +Images previously sent to this user (reference by id): +${imagesList}`, + }, + ...last3, + { role: 'user', content: userMessage }, + ]; +``` + +- [ ] **Step 5: Pass `phoneNumber`/`image_id`/`sendImage` through the webhook switch** + +Replace: + +```js + case 'check_allowed_edits': + await sendText(phoneNumber, '⏳ Checking allowed edits...'); + replyText = await actionCheckAllowedEdits(); + break; + + case 'edit_graphic': + await sendText(phoneNumber, '⏳ Applying edits to your graphic...'); + replyText = await actionEditGraphic(args.edits); + break; +``` + +with: + +```js + case 'check_allowed_edits': + await sendText(phoneNumber, '⏳ Checking allowed edits...'); + replyText = await actionCheckAllowedEdits(phoneNumber, args.image_id); + break; + + case 'edit_graphic': + await sendText(phoneNumber, '⏳ Applying edits to your graphic...'); + replyText = await actionEditGraphic(phoneNumber, args.image_id, args.edits, { sendImage }); + break; +``` + +- [ ] **Step 6: Verify the file is syntactically valid** + +Run: `node --check app.js` +Expected: no output (silent success) + +- [ ] **Step 7: Verify the server still boots and the webhook-verification route still works** + +Run: + +```bash +VERIFY_TOKEN=test WHATSAPP_PHONE_NUMBER_ID=123 WHATSAPP_TOKEN=test OPENAI_API_KEY=test node app.js & +SERVER_PID=$! +sleep 1 +curl -s "http://localhost:3000/?hub.mode=subscribe&hub.verify_token=test&hub.challenge=hello123" +kill $SERVER_PID +``` + +Expected: `hello123` printed by curl, followed by `WEBHOOK VERIFIED` in the server's stdout before it's killed. + +- [ ] **Step 8: Run the full test suite once more to confirm nothing broke** + +Run: `node --test` +Expected: PASS — all 15 tests passing + +- [ ] **Step 9: Commit** + +```bash +git add app.js +git commit -m "feat: wire image-edit flow into the webhook and GPT tool schema" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** template/layer lookup (Task 2), allowed-edit validation (Task 4 `actionEditGraphic`), Express edit + Meta upload + re-send (Task 4 + Task 5 wiring), image reference inferred by GPT from a system-prompt-provided list (Task 5 Step 4), pre-seeded mock images since real sending is out of scope (Task 1) — all covered. +- **Placeholder scan:** no TBDs; the two `// TODO: fetch from campaign API` / `// TODO: parse CSV/Excel...` comments are carried over unchanged from the existing code (out of scope for this feature) and are intentional, not gaps in this plan. +- **Type consistency:** `sendImage` signature `(to, link) => Promise` is consistent between `actions.js`'s `actionEditGraphic` (Task 4) and its call site in `app.js` (Task 5 Step 5), which passes the existing `sendImage(to, link)` from `app.js`. `image.id`/`image.name`/`image.templateId`/`image.currentEdits` field names are consistent across `imageStore.js`, `expressApi.js` consumers, and `actions.js`. diff --git a/docs/superpowers/specs/2026-07-13-express-edit-flow-design.md b/docs/superpowers/specs/2026-07-13-express-edit-flow-design.md new file mode 100644 index 0000000000..272d63626f --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-express-edit-flow-design.md @@ -0,0 +1,132 @@ +# Design: Image edit flow (Adobe Express, mocked) + +## Goal + +Support this WhatsApp conversation flow: + +1. User has previously been sent a message containing an image (graphic). +2. User asks what edits are allowed on one of those images. +3. System looks up which template the image used and which layers are unlocked (via Adobe Express API). +4. User asks for a specific edit. +5. System checks whether the requested edit is one of the allowed edits. +6. System calls the Adobe Express API to apply the edit. +7. System uploads the resulting image to Meta and gets back a URL. +8. System sends a new WhatsApp message with the updated image. + +Adobe Express API calls are out of scope for this iteration — all Express/Meta-upload calls are placeholder functions returning mock values, structured so they can be swapped for real implementations later without changing the surrounding flow. + +Actually sending the *initial* graphic image (step 1) is out of scope. Instead, a small set of mock "sent images" is pre-seeded at server startup for any phone number, so the rest of the flow has something to operate against. + +## Data model + +New in-memory store, `imageStore.js`, following the same per-phone-number Map pattern as the existing `conversationHistory`: + +```js +// Map> +// Seeded lazily on first access for a phone number — same 3 mock entries every time. +{ + id: 'img_1', // stable id GPT references in tool calls + name: 'Diwali Offer Banner', // human-readable, shown to GPT so it can match user text + templateId: 'tpl_diwali', // Express template id + currentEdits: {}, // accumulates applied edits across calls +} +``` + +Three seed entries (mirroring the existing `list_campaign_graphics` mock names): Diwali Offer Banner / Summer Sale Flyer / New Arrival Poster, each with a distinct mock `templateId`. + +`getTrackedImages(phoneNumber)` returns (seeding if needed) the array; `findTrackedImage(phoneNumber, imageId)` returns one entry or `undefined`. + +## GPT tool changes + +GPT is responsible for figuring out *which* tracked image a user's message refers to (per your choice of "GPT infers from text"), so the system prompt must include the phone number's tracked images (id + name) on every call to `decideAction`. Both relevant tools gain an `image_id` parameter: + +```js +check_allowed_edits({ image_id }) +edit_graphic({ image_id, edits }) +``` + +`image_id` is required on both. Tool descriptions instruct GPT to pick the id from the list provided in the system prompt, matching the user's description (e.g. "the Diwali one") to the closest tracked image name. + +System prompt gains a section like: + +``` +Images previously sent to this user (reference by id): +- img_1: Diwali Offer Banner +- img_2: Summer Sale Flyer +- img_3: New Arrival Poster +``` + +## Mock Adobe Express API (`expressApi.js`) + +Placeholder module, no network calls: + +```js +function getTemplateInfo(templateId) { + // Mock: returns different unlocked layers per template so behavior isn't uniform. + // e.g. tpl_diwali -> ['discount_text', 'headline', 'background_color'] + // tpl_summer -> ['headline', 'font_color'] + // tpl_newarrival -> ['headline'] +} + +function applyEdit(templateId, currentEdits, newEdits) { + // Mock: merges edits, returns a fake rendered-image reference + // e.g. { renderedImageUrl: 'https://mock-express.local/render/tpl_diwali?rev=3' } +} +``` + +Both are synchronous or trivially async (`Promise.resolve`) mocks — no real HTTP calls yet. + +## Mock Meta upload + +Added alongside the existing WhatsApp helpers in app.js (or a small `whatsapp.js` if we split further — not required for this scope): + +```js +async function uploadImageToMeta(renderedImageUrl) { + // Mock: pretend to upload to Meta's media endpoint, return a fake CDN url + // e.g. `https://mock-meta-cdn.local/media/.png` +} +``` + +`sendImage(to, link)` already exists and takes a link — the mock URL from `uploadImageToMeta` is passed straight into it. + +## Action handlers + +### `actionCheckAllowedEdits(phoneNumber, imageId)` + +1. `findTrackedImage(phoneNumber, imageId)` — if not found, return an error message ("I couldn't find that image — here's what I have: ..."). +2. `getTemplateInfo(image.templateId)` → `unlockedLayers`. +3. Return a formatted list of allowed edits for that specific image (not the generic hardcoded string currently returned). + +### `actionEditGraphic(phoneNumber, imageId, edits)` + +1. `findTrackedImage(phoneNumber, imageId)` — same not-found handling as above. +2. `getTemplateInfo(image.templateId)` → `unlockedLayers`. +3. Validate: every key in `edits` must be in `unlockedLayers`. If any key isn't allowed, return a message naming the rejected field(s) and listing the actually-allowed fields — **do not call Express or Meta**. This is the "system checks if this is one of the allowed edits" step, done in code, not by GPT. +4. `applyEdit(templateId, image.currentEdits, edits)` → mock render; merge `edits` into `image.currentEdits`. +5. `uploadImageToMeta(renderedImageUrl)` → mock URL. +6. `sendImage(phoneNumber, mockUrl)` — new WhatsApp message with the updated image. +7. Return a short confirming text (e.g. "Updated: discount_text → 70%") which is sent as a follow-up text message via the existing `sendText` call in the webhook handler. + +## Webhook wiring + +In `app.post('/')`, the `check_allowed_edits` and `edit_graphic` cases pass `args.image_id` (and `args.edits`) through to the updated action handlers. No change to the outer loop structure. + +## Error handling + +- Unknown `image_id` (GPT hallucination): friendly text response listing currently tracked images, no crash. +- Disallowed edit field(s): friendly text response naming what's not allowed and what is, no Express/Meta calls made. +- Mock functions never throw — real error handling (network failures, Express API errors) is deferred to when real API calls replace the mocks. + +## Testing + +Manual verification only for this mocked iteration (no live Express/Meta credentials exist yet): +- Simulate incoming webhook payloads for: "what edits can I make to the summer flyer", followed by "change the headline to Flash Sale" — confirm the reply lists the right mock unlocked layers and then confirms the edit + attempts a mock image send. +- Simulate an edit request for a field not in `unlockedLayers` — confirm it's rejected with the allowed-fields message and no image is sent. +- Simulate a reference to a nonexistent image — confirm the not-found message. + +## Out of scope + +- Real Adobe Express API integration. +- Real Meta media upload. +- Actually sending the initial graphic image that seeds the flow (images are pre-seeded in memory instead). +- Persistence across server restarts (in-memory only, matches existing `conversationHistory` pattern). diff --git a/expressApi.js b/expressApi.js new file mode 100644 index 0000000000..0a352c054d --- /dev/null +++ b/expressApi.js @@ -0,0 +1,33 @@ +const TEMPLATE_LAYERS = { + tpl_diwali: ['discount_text', 'headline', 'background_color'], + tpl_summer: ['headline', 'font_color'], + tpl_croma_earbuds: ['Price', 'Address', 'Product Image', 'Partner Logo'], +}; + +function getTemplateInfo(templateId) { + return { + templateId, + unlockedLayers: TEMPLATE_LAYERS[templateId] || [], + }; +} + +let renderRevision = 0; + +function applyEdit(templateId, currentEdits, newEdits) { + const mergedEdits = { ...currentEdits, ...newEdits }; + + if (templateId === 'tpl_croma_earbuds') { + return { + mergedEdits, + renderedImageUrl: 'https://s7ap1.scene7.com/is/image/varun/croma1-earbuds-updated', + }; + } + + renderRevision += 1; + return { + mergedEdits, + renderedImageUrl: `https://mock-express.local/render/${templateId}?rev=${renderRevision}`, + }; +} + +module.exports = { getTemplateInfo, applyEdit }; diff --git a/expressApi.test.js b/expressApi.test.js new file mode 100644 index 0000000000..c9b3126535 --- /dev/null +++ b/expressApi.test.js @@ -0,0 +1,39 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { getTemplateInfo, applyEdit } = require('./expressApi'); + +test('getTemplateInfo returns the unlocked layers for a known template', () => { + const info = getTemplateInfo('tpl_diwali'); + assert.deepEqual(info, { + templateId: 'tpl_diwali', + unlockedLayers: ['discount_text', 'headline', 'background_color'], + }); +}); + +test('getTemplateInfo returns the unlocked layers for the Croma earbuds template', () => { + const info = getTemplateInfo('tpl_croma_earbuds'); + assert.deepEqual(info, { + templateId: 'tpl_croma_earbuds', + unlockedLayers: ['Price', 'Address', 'Product Image', 'Partner Logo'], + }); +}); + +test('getTemplateInfo returns an empty layer list for an unknown template', () => { + const info = getTemplateInfo('tpl_does_not_exist'); + assert.deepEqual(info.unlockedLayers, []); +}); + +test('applyEdit merges new edits on top of current edits', () => { + const result = applyEdit('tpl_diwali', { headline: 'Old Headline' }, { discount_text: '70%' }); + assert.deepEqual(result.mergedEdits, { headline: 'Old Headline', discount_text: '70%' }); +}); + +test('applyEdit returns a rendered image url that references the template', () => { + const result = applyEdit('tpl_summer', {}, { headline: 'Flash Sale' }); + assert.match(result.renderedImageUrl, /^https:\/\/mock-express\.local\/render\/tpl_summer\?rev=\d+$/); +}); + +test('applyEdit returns the fixed updated Croma earbuds image for any edit', () => { + const result = applyEdit('tpl_croma_earbuds', {}, { Price: '999' }); + assert.equal(result.renderedImageUrl, 'https://s7ap1.scene7.com/is/image/varun/croma1-earbuds-updated'); +}); diff --git a/imageStore.js b/imageStore.js new file mode 100644 index 0000000000..136d365bc8 --- /dev/null +++ b/imageStore.js @@ -0,0 +1,28 @@ +const SEED_IMAGES = [ + { id: 'img_1', name: 'Diwali Offer Banner', templateId: 'tpl_diwali' }, + { id: 'img_2', name: 'Summer Sale Flyer', templateId: 'tpl_summer' }, + { + id: 'img_3', + name: 'Croma Earbuds', + templateId: 'tpl_croma_earbuds', + url: 'https://s7ap1.scene7.com/is/image/varun/croma1-earbuds', + }, +]; + +const trackedImages = new Map(); + +function getTrackedImages(phoneNumber) { + if (!trackedImages.has(phoneNumber)) { + trackedImages.set( + phoneNumber, + SEED_IMAGES.map((image) => ({ ...image, currentEdits: {} })) + ); + } + return trackedImages.get(phoneNumber); +} + +function findTrackedImage(phoneNumber, imageId) { + return getTrackedImages(phoneNumber).find((image) => image.id === imageId); +} + +module.exports = { getTrackedImages, findTrackedImage }; diff --git a/imageStore.test.js b/imageStore.test.js new file mode 100644 index 0000000000..841acf9d78 --- /dev/null +++ b/imageStore.test.js @@ -0,0 +1,35 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { getTrackedImages, findTrackedImage } = require('./imageStore'); + +test('getTrackedImages seeds 3 images on first access', () => { + const images = getTrackedImages('111'); + assert.equal(images.length, 3); + assert.deepEqual(images.map((img) => img.id), ['img_1', 'img_2', 'img_3']); + assert.deepEqual(images[0].currentEdits, {}); +}); + +test('getTrackedImages returns the same array on repeated calls for the same phone number', () => { + const first = getTrackedImages('222'); + first[0].currentEdits.headline = 'Flash Sale'; + const second = getTrackedImages('222'); + assert.equal(second[0].currentEdits.headline, 'Flash Sale'); +}); + +test('getTrackedImages seeds independently per phone number', () => { + getTrackedImages('333')[0].currentEdits.headline = 'Only for 333'; + const other = getTrackedImages('444'); + assert.deepEqual(other[0].currentEdits, {}); +}); + +test('findTrackedImage returns the matching image', () => { + getTrackedImages('555'); + const image = findTrackedImage('555', 'img_2'); + assert.equal(image.name, 'Summer Sale Flyer'); +}); + +test('findTrackedImage returns undefined for an unknown id', () => { + getTrackedImages('666'); + const image = findTrackedImage('666', 'img_999'); + assert.equal(image, undefined); +}); diff --git a/metaUpload.js b/metaUpload.js new file mode 100644 index 0000000000..4a6a68402b --- /dev/null +++ b/metaUpload.js @@ -0,0 +1,10 @@ +const { randomUUID } = require('node:crypto'); + +async function uploadImageToMeta(renderedImageUrl) { + if (!renderedImageUrl.startsWith('https://mock-express.local/')) { + return renderedImageUrl; + } + return `https://mock-meta-cdn.local/media/${randomUUID()}.png`; +} + +module.exports = { uploadImageToMeta }; diff --git a/metaUpload.test.js b/metaUpload.test.js new file mode 100644 index 0000000000..e4aed1b9ca --- /dev/null +++ b/metaUpload.test.js @@ -0,0 +1,19 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { uploadImageToMeta } = require('./metaUpload'); + +test('uploadImageToMeta returns a mock CDN url', async () => { + const url = await uploadImageToMeta('https://mock-express.local/render/tpl_diwali?rev=1'); + assert.match(url, /^https:\/\/mock-meta-cdn\.local\/media\/[0-9a-f-]+\.png$/); +}); + +test('uploadImageToMeta returns a different url on each call', async () => { + const first = await uploadImageToMeta('https://mock-express.local/render/tpl_diwali?rev=1'); + const second = await uploadImageToMeta('https://mock-express.local/render/tpl_diwali?rev=2'); + assert.notEqual(first, second); +}); + +test('uploadImageToMeta passes through urls that are already publicly hosted', async () => { + const url = await uploadImageToMeta('https://s7ap1.scene7.com/is/image/varun/croma1-earbuds-updated'); + assert.equal(url, 'https://s7ap1.scene7.com/is/image/varun/croma1-earbuds-updated'); +}); diff --git a/package.json b/package.json index 567801e154..854ad81ab5 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "license": "MIT", "private": false, "scripts": { - "start": "node app.js" + "start": "node app.js", + "test": "node --test" }, "dependencies": { "express": "^5.0.0", From 8bef9c07816687ab773855c7ad9bd1a930df2859 Mon Sep 17 00:00:00 2001 From: priyankmodiPM <32540484+priyankmodiPM@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:50:04 +0530 Subject: [PATCH 09/35] Feat/real express api integration (#5) * Add design spec for global fixed edit-flow responses Whenever an edit is requested, always send the fixed Croma earbuds image; whenever allowed-edits is asked, always reply with the fixed layer list. Removes per-template edit-key validation. Co-Authored-By: Claude Sonnet 5 * Add design spec for real Adobe Express API integration Replaces the mocked expressApi.js/imageStore.js/metaUpload.js flow with real tagged-documents/generate-variation/status calls against a shared docID catalog, per the brainstorming session. * Ignore .superpowers scratch directory Co-Authored-By: Claude Sonnet 5 * feat: add Adobe IMS token fetch/cache module Implement expressAuth.js with getAccessToken and buildAuthHeaders functions. Provides IMS token caching with 60s refresh margin per Adobe IMS v3 convention. Co-Authored-By: Claude Sonnet 5 * feat: read the shared docID catalog instead of hardcoded seed images Co-Authored-By: Claude Sonnet 5 * fix: validate catalog is an array in loadCatalog() If the catalog file parses successfully but isn't an array (e.g., `{}` or `42`), treat it as an invalid catalog, log the error, and return empty array instead of crashing on `.map()`. Aligns with design spec requirement to degrade gracefully on catalog read failures. Co-Authored-By: Claude Sonnet 5 * feat: replace mocked Express API with real tagged-documents/generate-variation/status calls Co-Authored-By: Claude Sonnet 5 * feat: wire actions.js to the real Express API and drop mocked edit validation * feat: log webhook metadata fields and declare Express API env vars * Fix cross-task issues from final branch review - Use the real statusUrl returned by generateVariation instead of reconstructing a status URL from apiBaseUrl()+jobId, which was an unverified assumption about the API's URL shape. - Overlay image.currentEdits onto tagged elements before formatting allowed-edits messages so previously edited fields show their latest value instead of the stale original document value. - Narrow the hardcoded campaign graphics list to match the real single-entry catalog (Croma Earbuds). - Guard the sendImage call in actionEditGraphic with try/catch so a delivery failure doesn't swallow an already-successful edit; users now get a "couldn't send" message instead of no reply at all. Co-Authored-By: Claude Sonnet 5 * Add implementation plan for real Adobe Express API integration Co-Authored-By: Claude Sonnet 5 * Move source files into src/, group Express API modules under src/express/ Keeps data/ (shared catalog) and docs/ at repo root. Updates imageStore.js's default catalog path, actions.js/actions.test.js's expressApi import, and the app.js entry point in package.json/render.yaml to match. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Priyank Modi Co-authored-by: Claude Sonnet 5 --- .gitignore | 1 + actions.js | 61 - actions.test.js | 66 -- data/express-templates.json | 3 + ...2026-07-16-real-express-api-integration.md | 1039 +++++++++++++++++ ...07-13-global-fixed-edit-response-design.md | 52 + ...-16-real-express-api-integration-design.md | 188 +++ expressApi.js | 33 - expressApi.test.js | 39 - imageStore.js | 28 - imageStore.test.js | 35 - package.json | 4 +- render.yaml | 18 +- src/actions.js | 100 ++ src/actions.test.js | 149 +++ app.js => src/app.js | 4 + src/express/expressApi.js | 102 ++ src/express/expressApi.test.js | 204 ++++ src/express/expressAuth.js | 44 + src/express/expressAuth.test.js | 90 ++ src/imageStore.js | 43 + src/imageStore.test.js | 75 ++ metaUpload.js => src/metaUpload.js | 0 metaUpload.test.js => src/metaUpload.test.js | 0 24 files changed, 2113 insertions(+), 265 deletions(-) delete mode 100644 actions.js delete mode 100644 actions.test.js create mode 100644 data/express-templates.json create mode 100644 docs/superpowers/plans/2026-07-16-real-express-api-integration.md create mode 100644 docs/superpowers/specs/2026-07-13-global-fixed-edit-response-design.md create mode 100644 docs/superpowers/specs/2026-07-16-real-express-api-integration-design.md delete mode 100644 expressApi.js delete mode 100644 expressApi.test.js delete mode 100644 imageStore.js delete mode 100644 imageStore.test.js create mode 100644 src/actions.js create mode 100644 src/actions.test.js rename app.js => src/app.js (96%) create mode 100644 src/express/expressApi.js create mode 100644 src/express/expressApi.test.js create mode 100644 src/express/expressAuth.js create mode 100644 src/express/expressAuth.test.js create mode 100644 src/imageStore.js create mode 100644 src/imageStore.test.js rename metaUpload.js => src/metaUpload.js (100%) rename metaUpload.test.js => src/metaUpload.test.js (100%) diff --git a/.gitignore b/.gitignore index fd4f2b066b..84888f4ca4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules .DS_Store +.superpowers diff --git a/actions.js b/actions.js deleted file mode 100644 index a990e7b913..0000000000 --- a/actions.js +++ /dev/null @@ -1,61 +0,0 @@ -const { getTrackedImages, findTrackedImage } = require('./imageStore'); -const { getTemplateInfo, applyEdit } = require('./expressApi'); -const { uploadImageToMeta } = require('./metaUpload'); - -function formatUnknownImageMessage(phoneNumber) { - const images = getTrackedImages(phoneNumber); - const list = images.map((image) => `- ${image.name}`).join('\n'); - return `I couldn't find that image. Here's what I have:\n${list}`; -} - -async function actionListCampaignGraphics() { - // TODO: fetch from campaign API - return 'Graphics in your current campaign:\n1. Diwali Offer Banner\n2. Summer Sale Flyer\n3. Croma Earbuds'; -} - -async function actionCheckAllowedEdits(phoneNumber, imageId) { - const image = findTrackedImage(phoneNumber, imageId); - if (!image) { - return formatUnknownImageMessage(phoneNumber); - } - const { unlockedLayers } = getTemplateInfo(image.templateId); - const layerList = unlockedLayers.map((layer) => `- ${layer}`).join('\n'); - return `Edits allowed on "${image.name}":\n${layerList}`; -} - -async function actionEditGraphic(phoneNumber, imageId, edits, { sendImage }) { - const image = findTrackedImage(phoneNumber, imageId); - if (!image) { - return formatUnknownImageMessage(phoneNumber); - } - - const { unlockedLayers } = getTemplateInfo(image.templateId); - const requestedKeys = Object.keys(edits || {}); - const disallowedKeys = requestedKeys.filter((key) => !unlockedLayers.includes(key)); - - if (disallowedKeys.length > 0) { - const allowedList = unlockedLayers.map((layer) => `- ${layer}`).join('\n'); - return `I can't edit ${disallowedKeys.join(', ')} on "${image.name}". Allowed edits:\n${allowedList}`; - } - - const { mergedEdits, renderedImageUrl } = applyEdit(image.templateId, image.currentEdits, edits); - image.currentEdits = mergedEdits; - - const uploadedUrl = await uploadImageToMeta(renderedImageUrl); - await sendImage(phoneNumber, uploadedUrl); - - const summary = Object.entries(edits).map(([key, value]) => `• ${key}: ${value}`).join('\n'); - return `Updated "${image.name}":\n${summary}`; -} - -async function actionGenerateBulkGraphics(filename) { - // TODO: parse CSV/Excel and call Adobe Express API per row - return `Bulk generation complete! Graphics created from ${filename || 'your uploaded file'}.`; -} - -module.exports = { - actionListCampaignGraphics, - actionCheckAllowedEdits, - actionEditGraphic, - actionGenerateBulkGraphics, -}; diff --git a/actions.test.js b/actions.test.js deleted file mode 100644 index fa88280a39..0000000000 --- a/actions.test.js +++ /dev/null @@ -1,66 +0,0 @@ -const test = require('node:test'); -const assert = require('node:assert/strict'); -const { actionCheckAllowedEdits, actionEditGraphic } = require('./actions'); -const { getTrackedImages } = require('./imageStore'); - -test('actionCheckAllowedEdits lists the unlocked layers for a known image', async () => { - const reply = await actionCheckAllowedEdits('phone-1', 'img_1'); - assert.match(reply, /Diwali Offer Banner/); - assert.match(reply, /discount_text/); -}); - -test('actionCheckAllowedEdits lists Price, Address, Product Image, Partner Logo for the Croma earbuds image', async () => { - const reply = await actionCheckAllowedEdits('phone-croma', 'img_3'); - assert.match(reply, /Croma Earbuds/); - assert.match(reply, /- Price/); - assert.match(reply, /- Address/); - assert.match(reply, /- Product Image/); - assert.match(reply, /- Partner Logo/); -}); - -test('actionCheckAllowedEdits reports unknown images without throwing', async () => { - const reply = await actionCheckAllowedEdits('phone-2', 'img_nope'); - assert.match(reply, /couldn't find that image/); -}); - -test('actionEditGraphic rejects edits outside the unlocked layers and sends nothing', async () => { - let sendImageCalled = false; - const sendImage = async () => { - sendImageCalled = true; - }; - - const reply = await actionEditGraphic('phone-3', 'img_3', { background_color: 'red' }, { sendImage }); - - assert.match(reply, /can't edit background_color/); - assert.equal(sendImageCalled, false); -}); - -test('actionEditGraphic sends the fixed updated Croma earbuds image for any allowed edit', async () => { - const sentCalls = []; - const sendImage = async (to, link) => { - sentCalls.push({ to, link }); - }; - - const reply = await actionEditGraphic('phone-5', 'img_3', { Price: '999' }, { sendImage }); - - assert.match(reply, /Updated "Croma Earbuds"/); - assert.equal(sentCalls.length, 1); - assert.equal(sentCalls[0].link, 'https://s7ap1.scene7.com/is/image/varun/croma1-earbuds-updated'); -}); - -test('actionEditGraphic applies an allowed edit, sends the updated image, and remembers the edit', async () => { - const sentCalls = []; - const sendImage = async (to, link) => { - sentCalls.push({ to, link }); - }; - - const reply = await actionEditGraphic('phone-4', 'img_2', { headline: 'Flash Sale' }, { sendImage }); - - assert.match(reply, /Updated "Summer Sale Flyer"/); - assert.equal(sentCalls.length, 1); - assert.equal(sentCalls[0].to, 'phone-4'); - assert.match(sentCalls[0].link, /^https:\/\/mock-meta-cdn\.local\//); - - const image = getTrackedImages('phone-4').find((img) => img.id === 'img_2'); - assert.equal(image.currentEdits.headline, 'Flash Sale'); -}); diff --git a/data/express-templates.json b/data/express-templates.json new file mode 100644 index 0000000000..79fb4b86fc --- /dev/null +++ b/data/express-templates.json @@ -0,0 +1,3 @@ +[ + { "id": "img_1", "name": "Croma Earbuds", "docId": "urn:aaid:sc:AP:aaed427c-b4e4-55e4-b924-74d375f91684" } +] diff --git a/docs/superpowers/plans/2026-07-16-real-express-api-integration.md b/docs/superpowers/plans/2026-07-16-real-express-api-integration.md new file mode 100644 index 0000000000..4762840bb3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-real-express-api-integration.md @@ -0,0 +1,1039 @@ +# Real Adobe Express API Integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace every mocked/hardcoded piece of the image-edit flow with real Adobe Express API calls (`tagged-documents`, `generate-variation`, `status`), driven by a shared `docId` catalog, ending with the real edited-image `thumbnailUrl` sent back over WhatsApp. + +**Architecture:** A new `expressAuth.js` handles IMS OAuth token fetch/cache. `expressApi.js` is rewritten to make real HTTP calls (tagged-document lookup, variation generation, status polling) plus small formatting/helper functions, using `expressAuth.js` for auth headers. `imageStore.js` is rewritten to read a shared JSON catalog (`data/express-templates.json`, `id`/`name`/`docId`) fresh on every call instead of hardcoded seed data, layering in-memory per-conversation edit state on top. `actions.js` is rewired to call the real `expressApi.js` functions (via a namespace import so tests can stub them directly) instead of the old mocks, with all Express API failures caught and turned into friendly WhatsApp replies. `app.js` gets a few extra labeled `console.log` lines for webhook fields that might carry docID metadata — no other behavior change. `metaUpload.js` is left completely untouched and unused. + +**Tech Stack:** Node.js 20, Express 5, `openai` SDK, Node's built-in `node:test` + `node:assert/strict` + global `fetch` (no new dependencies). + +## Global Constraints + +- No new npm dependencies — use Node's built-in `node:test` test runner and global `fetch`, stubbed directly in tests (no mocking library). +- Follow existing code style: CommonJS `require`, 2-space indentation, semicolons. +- Real Adobe Express HTTP errors must never reach the WhatsApp user raw — every call site catches and replaces them with a short friendly retry message, logging the technical detail (status/body/docId/jobId) server-side via `console.error`. +- `data/express-templates.json` is committed to git (not gitignored) and is a flat, phone-number-agnostic catalog: `[{ id, name, docId }]`. No per-phone-number entries. +- IMS token responses' `expires_in` is treated as **milliseconds** (Adobe IMS v3 convention), added directly to `Date.now()` — flagged as an assumption to verify against the real endpoint; isolated to one line in `expressAuth.js` if it needs to change to `* 1000`. +- `metaUpload.js` and `metaUpload.test.js` stay in the repo, completely unmodified, and are not imported anywhere after this change. +- A failed or timed-out edit must not be recorded into the per-conversation edit state — only a successful `generate-variation` + `status: succeeded` commits the edit, so retries don't compound bad state. + +--- + +## Task 1: `expressAuth.js` — IMS token fetch/cache + +**Files:** +- Create: `expressAuth.js` +- Test: `expressAuth.test.js` + +**Interfaces:** +- Consumes: env vars `EXPRESS_CLIENT_ID`, `EXPRESS_CLIENT_SECRET`, `EXPRESS_API_SCOPE` (optional), `EXPRESS_IMS_TOKEN_URL` (optional). +- Produces: `getAccessToken() -> Promise` (cached IMS access token, refetches within 60s of expiry). `buildAuthHeaders() -> Promise<{ Authorization: string, 'X-API-KEY': string }>`. + +- [ ] **Step 1: Write the failing tests** + +Create `expressAuth.test.js`: + +```js +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const originalFetch = global.fetch; + +function freshExpressAuth() { + delete require.cache[require.resolve('./expressAuth')]; + return require('./expressAuth'); +} + +test('getAccessToken fetches a token from the IMS endpoint using client credentials', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-123'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-456'; + global.fetch = async (url, options) => { + assert.equal(url, 'https://ims-na1.adobelogin.com/ims/token/v3'); + assert.equal(options.method, 'POST'); + assert.equal(options.headers['Content-Type'], 'application/x-www-form-urlencoded'); + const body = options.body.toString(); + assert.match(body, /grant_type=client_credentials/); + assert.match(body, /client_id=client-123/); + assert.match(body, /client_secret=secret-456/); + return { ok: true, json: async () => ({ access_token: 'tok-abc', expires_in: 86400000, token_type: 'bearer' }) }; + }; + + const { getAccessToken } = freshExpressAuth(); + const token = await getAccessToken(); + + assert.equal(token, 'tok-abc'); + global.fetch = originalFetch; +}); + +test('getAccessToken caches the token and does not refetch on a second call', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-123'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-456'; + let fetchCalls = 0; + global.fetch = async () => { + fetchCalls += 1; + return { ok: true, json: async () => ({ access_token: 'tok-cached', expires_in: 86400000 }) }; + }; + + const { getAccessToken } = freshExpressAuth(); + const first = await getAccessToken(); + const second = await getAccessToken(); + + assert.equal(first, 'tok-cached'); + assert.equal(second, 'tok-cached'); + assert.equal(fetchCalls, 1); + global.fetch = originalFetch; +}); + +test('getAccessToken refetches once the cached token is within 60s of expiring', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-123'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-456'; + let fetchCalls = 0; + global.fetch = async () => { + fetchCalls += 1; + return { ok: true, json: async () => ({ access_token: `tok-${fetchCalls}`, expires_in: 30000 }) }; + }; + + const { getAccessToken } = freshExpressAuth(); + const first = await getAccessToken(); + const second = await getAccessToken(); + + assert.equal(first, 'tok-1'); + assert.equal(second, 'tok-2'); + assert.equal(fetchCalls, 2); + global.fetch = originalFetch; +}); + +test('getAccessToken throws with the status and body when the IMS endpoint errors', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-123'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-456'; + global.fetch = async () => ({ ok: false, status: 401, text: async () => 'invalid client' }); + + const { getAccessToken } = freshExpressAuth(); + await assert.rejects(() => getAccessToken(), /401/); + global.fetch = originalFetch; +}); + +test('buildAuthHeaders returns Authorization and X-API-KEY headers', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-789'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-000'; + global.fetch = async () => ({ ok: true, json: async () => ({ access_token: 'tok-xyz', expires_in: 86400000 }) }); + + const { buildAuthHeaders } = freshExpressAuth(); + const headers = await buildAuthHeaders(); + + assert.deepEqual(headers, { Authorization: 'Bearer tok-xyz', 'X-API-KEY': 'client-789' }); + global.fetch = originalFetch; +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `node --test expressAuth.test.js` +Expected: FAIL — `Cannot find module './expressAuth'` + +- [ ] **Step 3: Write the implementation** + +Create `expressAuth.js`: + +```js +const IMS_TOKEN_URL = process.env.EXPRESS_IMS_TOKEN_URL || 'https://ims-na1.adobelogin.com/ims/token/v3'; +const DEFAULT_SCOPE = 'ee.express_api,openid,AdobeID,read_organizations,additional_info.projectedProductContext'; +const REFRESH_MARGIN_MS = 60_000; + +let cachedToken = null; // { accessToken, expiresAt } + +async function getAccessToken() { + if (cachedToken && cachedToken.expiresAt - Date.now() > REFRESH_MARGIN_MS) { + return cachedToken.accessToken; + } + + const response = await fetch(IMS_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: process.env.EXPRESS_CLIENT_ID, + client_secret: process.env.EXPRESS_CLIENT_SECRET, + scope: process.env.EXPRESS_API_SCOPE || DEFAULT_SCOPE, + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`IMS token request failed ${response.status}: ${text}`); + } + + const data = await response.json(); + cachedToken = { + accessToken: data.access_token, + expiresAt: Date.now() + Number(data.expires_in), + }; + return cachedToken.accessToken; +} + +async function buildAuthHeaders() { + const token = await getAccessToken(); + return { + Authorization: `Bearer ${token}`, + 'X-API-KEY': process.env.EXPRESS_CLIENT_ID, + }; +} + +module.exports = { getAccessToken, buildAuthHeaders }; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test expressAuth.test.js` +Expected: PASS — 5 tests passing + +- [ ] **Step 5: Commit** + +```bash +git add expressAuth.js expressAuth.test.js +git commit -m "feat: add Adobe IMS token fetch/cache module" +``` + +--- + +## Task 2: `imageStore.js` — real catalog-backed image store + +**Files:** +- Create: `data/express-templates.json` +- Modify: `imageStore.js` (full rewrite) +- Modify: `imageStore.test.js` (full rewrite) + +**Interfaces:** +- Consumes: env var `EXPRESS_TEMPLATES_FILE` (optional, defaults to `data/express-templates.json` relative to this file). +- Produces: `getTrackedImages(phoneNumber) -> Array<{ id, name, docId, currentEdits }>`, `findTrackedImage(phoneNumber, imageId) -> object | undefined`, `recordEdits(phoneNumber, imageId, newEdits) -> object` (merges and returns the new `currentEdits`). + +- [ ] **Step 1: Write the failing tests** + +Create `imageStore.test.js`: + +```js +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { getTrackedImages, findTrackedImage, recordEdits } = require('./imageStore'); + +function writeFixtureCatalog(entries) { + const fixturePath = path.join(os.tmpdir(), `express-templates-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + fs.writeFileSync(fixturePath, JSON.stringify(entries)); + process.env.EXPRESS_TEMPLATES_FILE = fixturePath; +} + +test('getTrackedImages reads the catalog from EXPRESS_TEMPLATES_FILE', () => { + writeFixtureCatalog([ + { id: 'img_1', name: 'Diwali Offer Banner', docId: 'urn:doc:1' }, + { id: 'img_2', name: 'Summer Sale Flyer', docId: 'urn:doc:2' }, + ]); + + const images = getTrackedImages('phone-1'); + + assert.equal(images.length, 2); + assert.deepEqual(images[0], { id: 'img_1', name: 'Diwali Offer Banner', docId: 'urn:doc:1', currentEdits: {} }); +}); + +test('getTrackedImages returns an empty list when the catalog file is missing', () => { + process.env.EXPRESS_TEMPLATES_FILE = path.join(os.tmpdir(), 'does-not-exist.json'); + + const images = getTrackedImages('phone-2'); + + assert.deepEqual(images, []); +}); + +test('findTrackedImage returns the matching image by id', () => { + writeFixtureCatalog([{ id: 'img_3', name: 'Croma Earbuds', docId: 'urn:doc:3' }]); + + const image = findTrackedImage('phone-3', 'img_3'); + + assert.equal(image.name, 'Croma Earbuds'); +}); + +test('findTrackedImage returns undefined for an unknown id', () => { + writeFixtureCatalog([{ id: 'img_3', name: 'Croma Earbuds', docId: 'urn:doc:3' }]); + + const image = findTrackedImage('phone-4', 'img_nope'); + + assert.equal(image, undefined); +}); + +test('recordEdits merges edits per phone number and image id, visible via findTrackedImage', () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Diwali Offer Banner', docId: 'urn:doc:1' }]); + + recordEdits('phone-5', 'img_1', { headline: 'Flash Sale' }); + recordEdits('phone-5', 'img_1', { discount_text: '70%' }); + + const image = findTrackedImage('phone-5', 'img_1'); + assert.deepEqual(image.currentEdits, { headline: 'Flash Sale', discount_text: '70%' }); +}); + +test('recordEdits keeps edits independent per phone number', () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Diwali Offer Banner', docId: 'urn:doc:1' }]); + + recordEdits('phone-6', 'img_1', { headline: 'Only for phone-6' }); + + const otherPhoneImage = findTrackedImage('phone-7', 'img_1'); + assert.deepEqual(otherPhoneImage.currentEdits, {}); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `node --test imageStore.test.js` +Expected: FAIL — assertions fail against the old hardcoded `SEED_IMAGES` behavior (module exists but returns the wrong shape/values) + +- [ ] **Step 3: Write the implementation** + +Replace the contents of `imageStore.js`: + +```js +const fs = require('node:fs'); +const path = require('node:path'); + +function catalogPath() { + return process.env.EXPRESS_TEMPLATES_FILE || path.join(__dirname, 'data', 'express-templates.json'); +} + +function loadCatalog() { + try { + const raw = fs.readFileSync(catalogPath(), 'utf8'); + return JSON.parse(raw); + } catch (err) { + console.error('[imageStore] failed to read catalog', { path: catalogPath(), message: err.message }); + return []; + } +} + +const conversationEdits = new Map(); + +function getTrackedImages(phoneNumber) { + return loadCatalog().map((entry) => ({ + ...entry, + currentEdits: conversationEdits.get(`${phoneNumber}:${entry.id}`) || {}, + })); +} + +function findTrackedImage(phoneNumber, imageId) { + return getTrackedImages(phoneNumber).find((image) => image.id === imageId); +} + +function recordEdits(phoneNumber, imageId, newEdits) { + const key = `${phoneNumber}:${imageId}`; + const merged = { ...(conversationEdits.get(key) || {}), ...newEdits }; + conversationEdits.set(key, merged); + return merged; +} + +module.exports = { getTrackedImages, findTrackedImage, recordEdits }; +``` + +- [ ] **Step 4: Create the real catalog file** + +Create `data/express-templates.json` with the one confirmed real entry (the Diwali/Summer entries from the old mock had no real Express document behind them, so they're not carried forward as fake data — add more entries here once the UI app provides their real `docId`s): + +```json +[ + { "id": "img_1", "name": "Croma Earbuds", "docId": "urn:aaid:sc:AP:aaed427c-b4e4-55e4-b924-74d375f91684" } +] +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `node --test imageStore.test.js` +Expected: PASS — 6 tests passing + +- [ ] **Step 6: Commit** + +```bash +git add imageStore.js imageStore.test.js data/express-templates.json +git commit -m "feat: read the shared docID catalog instead of hardcoded seed images" +``` + +--- + +## Task 3: `expressApi.js` — real Adobe Express HTTP calls + +**Files:** +- Modify: `expressApi.js` (full rewrite) +- Modify: `expressApi.test.js` (full rewrite) + +**Interfaces:** +- Consumes: `buildAuthHeaders` from `./expressAuth` (Task 1); env vars `EXPRESS_API_BASE_URL`, `EXPRESS_STATUS_POLL_INTERVAL_MS`, `EXPRESS_STATUS_POLL_TIMEOUT_MS` (all optional). +- Produces: `getTaggedDocument(docId) -> Promise`, `generateVariation(docId, tagMappings, pages, preferredDocumentName) -> Promise<{ jobId, statusUrl }>`, `getJobStatus(jobId) -> Promise`, `pollJobStatus(jobId, { intervalMs?, timeoutMs? }) -> Promise`, `collectTaggedElements(taggedDocument) -> Array<{ name, type, value?, pageNumber }>`, `formatAllowedEdits(name, elements) -> string`, `pagesForEdits(elements, editKeys) -> string`, `buildPreferredDocumentName(baseName) -> string`. + +- [ ] **Step 1: Write the failing tests** + +Create `expressApi.test.js`: + +```js +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + getTaggedDocument, + generateVariation, + getJobStatus, + pollJobStatus, + collectTaggedElements, + formatAllowedEdits, + pagesForEdits, + buildPreferredDocumentName, +} = require('./expressApi'); + +const originalFetch = global.fetch; + +function stubFetch(handlers) { + global.fetch = async (url, options) => { + if (url.includes('ims-na1.adobelogin.com')) { + return { ok: true, json: async () => ({ access_token: 'tok-test', expires_in: 86400000 }) }; + } + for (const [pattern, handler] of handlers) { + if (pattern.test(url)) return handler(url, options); + } + throw new Error(`Unexpected fetch call: ${url}`); + }; +} + +test('getTaggedDocument fetches and returns the tagged document', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + stubFetch([ + [/\/beta\/tagged-documents\//, async (url, options) => { + assert.match(url, /\/beta\/tagged-documents\/urn%3Aaaid%3Asc%3AAP%3Aabc$/); + assert.equal(options.headers.Authorization, 'Bearer tok-test'); + assert.equal(options.headers['X-API-KEY'], 'client-1'); + return { ok: true, json: async () => ({ name: 'Croma2-Doc', id: 'urn:aaid:sc:AP:abc', documentPages: [] }) }; + }], + ]); + + const doc = await getTaggedDocument('urn:aaid:sc:AP:abc'); + + assert.equal(doc.name, 'Croma2-Doc'); + global.fetch = originalFetch; +}); + +test('getTaggedDocument throws with the status and body on a non-ok response', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + stubFetch([ + [/\/beta\/tagged-documents\//, async () => ({ ok: false, status: 404, text: async () => 'not found' })], + ]); + + await assert.rejects(() => getTaggedDocument('urn:missing'), /404/); + global.fetch = originalFetch; +}); + +test('generateVariation posts the right body and returns jobId/statusUrl', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + stubFetch([ + [/\/beta\/generate-variation$/, async (url, options) => { + assert.equal(options.method, 'POST'); + const body = JSON.parse(options.body); + assert.deepEqual(body, { + id: 'urn:doc:1', + variationDetails: { + pages: '1', + preferredDocumentName: 'Croma Earbuds-edit-123', + tagMappings: { cta: '20% off' }, + }, + }); + return { ok: true, json: async () => ({ jobId: 'job-1', statusUrl: 'https://express-api.adobe.io/status/job-1' }) }; + }], + ]); + + const result = await generateVariation('urn:doc:1', { cta: '20% off' }, '1', 'Croma Earbuds-edit-123'); + + assert.deepEqual(result, { jobId: 'job-1', statusUrl: 'https://express-api.adobe.io/status/job-1' }); + global.fetch = originalFetch; +}); + +test('getJobStatus returns the parsed status response', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + stubFetch([ + [/\/status\//, async () => ({ + ok: true, + json: async () => ({ jobId: 'job-1', status: 'succeeded', document: { name: 'GD2.express', id: 'urn:doc:2', thumbnailUrl: 'https://example.com/thumb.png' } }), + })], + ]); + + const result = await getJobStatus('job-1'); + + assert.equal(result.status, 'succeeded'); + assert.equal(result.document.thumbnailUrl, 'https://example.com/thumb.png'); + global.fetch = originalFetch; +}); + +test('pollJobStatus resolves once status is succeeded', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + let calls = 0; + stubFetch([ + [/\/status\//, async () => { + calls += 1; + const status = calls < 2 ? 'running' : 'succeeded'; + return { + ok: true, + json: async () => ({ + jobId: 'job-2', + status, + document: status === 'succeeded' ? { thumbnailUrl: 'https://example.com/thumb2.png' } : undefined, + }), + }; + }], + ]); + + const result = await pollJobStatus('job-2', { intervalMs: 1, timeoutMs: 1000 }); + + assert.equal(result.status, 'succeeded'); + assert.equal(calls, 2); + global.fetch = originalFetch; +}); + +test('pollJobStatus throws when status is failed', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + stubFetch([ + [/\/status\//, async () => ({ ok: true, json: async () => ({ jobId: 'job-3', status: 'failed' }) })], + ]); + + await assert.rejects(() => pollJobStatus('job-3', { intervalMs: 1, timeoutMs: 1000 }), /failed/); + global.fetch = originalFetch; +}); + +test('pollJobStatus throws once the timeout elapses without succeeding', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + stubFetch([ + [/\/status\//, async () => ({ ok: true, json: async () => ({ jobId: 'job-4', status: 'running' }) })], + ]); + + await assert.rejects(() => pollJobStatus('job-4', { intervalMs: 5, timeoutMs: 20 }), /timed out/); + global.fetch = originalFetch; +}); + +test('collectTaggedElements flattens taggedElements across all pages with pageNumber attached', () => { + const doc = { + documentPages: [ + { pageNumber: 1, taggedElements: [{ name: 'heading', type: 'text', value: 'Hi' }] }, + { pageNumber: 2, taggedElements: [{ name: 'footer', type: 'text', value: 'Bye' }] }, + ], + }; + + const elements = collectTaggedElements(doc); + + assert.deepEqual(elements, [ + { name: 'heading', type: 'text', value: 'Hi', pageNumber: 1 }, + { name: 'footer', type: 'text', value: 'Bye', pageNumber: 2 }, + ]); +}); + +test('formatAllowedEdits lists text elements with their current value and non-text elements with just their type', () => { + const elements = [ + { name: 'heading', type: 'text', value: 'Hi', pageNumber: 1 }, + { name: 'logo', type: 'image', pageNumber: 1 }, + ]; + + const message = formatAllowedEdits('Croma Earbuds', elements); + + assert.match(message, /Edits allowed on "Croma Earbuds":/); + assert.match(message, /- heading: currently "Hi"/); + assert.match(message, /- logo \(image\)/); +}); + +test('pagesForEdits returns the sorted, comma-joined page numbers containing the edited fields', () => { + const elements = [ + { name: 'heading', pageNumber: 2 }, + { name: 'cta', pageNumber: 1 }, + { name: 'footer', pageNumber: 1 }, + ]; + + assert.equal(pagesForEdits(elements, ['cta']), '1'); + assert.equal(pagesForEdits(elements, ['cta', 'heading']), '1,2'); +}); + +test('buildPreferredDocumentName appends a timestamp suffix to the base name', () => { + const name = buildPreferredDocumentName('Croma Earbuds'); + assert.match(name, /^Croma Earbuds-edit-\d+$/); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `node --test expressApi.test.js` +Expected: FAIL — old mocked `getTemplateInfo`/`applyEdit` exports don't match the new functions being imported + +- [ ] **Step 3: Write the implementation** + +Replace the contents of `expressApi.js`: + +```js +const { buildAuthHeaders } = require('./expressAuth'); + +function apiBaseUrl() { + return process.env.EXPRESS_API_BASE_URL || 'https://express-api.adobe.io'; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function getTaggedDocument(docId) { + const headers = await buildAuthHeaders(); + const response = await fetch(`${apiBaseUrl()}/beta/tagged-documents/${encodeURIComponent(docId)}`, { headers }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`getTaggedDocument failed ${response.status}: ${text}`); + } + return response.json(); +} + +async function generateVariation(docId, tagMappings, pages, preferredDocumentName) { + const headers = await buildAuthHeaders(); + const response = await fetch(`${apiBaseUrl()}/beta/generate-variation`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: docId, + variationDetails: { pages, preferredDocumentName, tagMappings }, + }), + }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`generateVariation failed ${response.status}: ${text}`); + } + return response.json(); +} + +async function getJobStatus(jobId) { + const headers = await buildAuthHeaders(); + const response = await fetch(`${apiBaseUrl()}/status/${encodeURIComponent(jobId)}`, { headers }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`getJobStatus failed ${response.status}: ${text}`); + } + return response.json(); +} + +async function pollJobStatus(jobId, { intervalMs, timeoutMs } = {}) { + const interval = intervalMs ?? Number(process.env.EXPRESS_STATUS_POLL_INTERVAL_MS || 2000); + const timeout = timeoutMs ?? Number(process.env.EXPRESS_STATUS_POLL_TIMEOUT_MS || 60000); + const deadline = Date.now() + timeout; + + for (;;) { + const result = await getJobStatus(jobId); + if (result.status === 'succeeded') return result; + if (result.status === 'failed') throw new Error(`Express job ${jobId} failed`); + if (Date.now() >= deadline) throw new Error(`Express job ${jobId} timed out after ${timeout}ms`); + await sleep(interval); + } +} + +function collectTaggedElements(taggedDocument) { + const elements = []; + for (const page of taggedDocument.documentPages || []) { + for (const element of page.taggedElements || []) { + elements.push({ ...element, pageNumber: page.pageNumber }); + } + } + return elements; +} + +function formatAllowedEdits(name, elements) { + const lines = elements.map((element) => + element.type === 'text' + ? `- ${element.name}: currently "${element.value}"` + : `- ${element.name} (${element.type})` + ); + const example = elements[0]?.name || 'a field'; + return `Edits allowed on "${name}":\n${lines.join('\n')}\nTell me what you'd like to change and to what, e.g. "change ${example} to ...".`; +} + +function pagesForEdits(elements, editKeys) { + const pageNumbers = new Set( + elements.filter((element) => editKeys.includes(element.name)).map((element) => element.pageNumber) + ); + return [...pageNumbers].sort((a, b) => a - b).join(','); +} + +function buildPreferredDocumentName(baseName) { + return `${baseName}-edit-${Date.now()}`; +} + +module.exports = { + getTaggedDocument, + generateVariation, + getJobStatus, + pollJobStatus, + collectTaggedElements, + formatAllowedEdits, + pagesForEdits, + buildPreferredDocumentName, +}; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test expressApi.test.js` +Expected: PASS — 11 tests passing + +- [ ] **Step 5: Commit** + +```bash +git add expressApi.js expressApi.test.js +git commit -m "feat: replace mocked Express API with real tagged-documents/generate-variation/status calls" +``` + +--- + +## Task 4: `actions.js` — wire the real edit flow + +**Files:** +- Modify: `actions.js` (full rewrite) +- Modify: `actions.test.js` (full rewrite) + +**Interfaces:** +- Consumes: `getTrackedImages`, `findTrackedImage`, `recordEdits` from `./imageStore` (Task 2); the `expressApi` module as a **namespace import** (`const expressApi = require('./expressApi')`, not destructured) from Task 3, specifically so tests can stub `expressApi.getTaggedDocument`/`generateVariation`/`pollJobStatus` directly without touching `global.fetch`. +- Produces: `actionListCampaignGraphics() -> Promise` (unchanged), `actionCheckAllowedEdits(phoneNumber, imageId) -> Promise`, `actionEditGraphic(phoneNumber, imageId, edits, { sendImage }) -> Promise`, `actionGenerateBulkGraphics(filename) -> Promise` (unchanged). + +- [ ] **Step 1: Write the failing tests** + +Create `actions.test.js`: + +```js +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { actionCheckAllowedEdits, actionEditGraphic } = require('./actions'); +const expressApi = require('./expressApi'); +const { findTrackedImage } = require('./imageStore'); + +function writeFixtureCatalog(entries) { + const fixturePath = path.join(os.tmpdir(), `express-templates-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + fs.writeFileSync(fixturePath, JSON.stringify(entries)); + process.env.EXPRESS_TEMPLATES_FILE = fixturePath; +} + +const SAMPLE_ELEMENTS_DOC = { + documentPages: [ + { + pageNumber: 1, + taggedElements: [ + { name: 'heading', type: 'text', value: 'The X-Phone Pro is here!' }, + { name: 'cta', type: 'text', value: 'Available at our store starting 15 Aug 20XX.' }, + ], + }, + ], +}; + +test('actionCheckAllowedEdits lists the tagged elements for a known image', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async (docId) => { + assert.equal(docId, 'urn:doc:1'); + return SAMPLE_ELEMENTS_DOC; + }; + + const reply = await actionCheckAllowedEdits('phone-1', 'img_1'); + + assert.match(reply, /Croma Earbuds/); + assert.match(reply, /heading: currently "The X-Phone Pro is here!"/); + assert.match(reply, /cta: currently/); +}); + +test('actionCheckAllowedEdits reports unknown images without throwing', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + + const reply = await actionCheckAllowedEdits('phone-2', 'img_nope'); + + assert.match(reply, /couldn't find that image/); +}); + +test('actionCheckAllowedEdits returns a friendly message when the Express API call fails', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async () => { + throw new Error('getTaggedDocument failed 500: boom'); + }; + + const reply = await actionCheckAllowedEdits('phone-3', 'img_1'); + + assert.match(reply, /couldn't check the allowed edits/); +}); + +test('actionEditGraphic rejects edits outside the tagged elements and makes no generate call', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; + expressApi.generateVariation = async () => { + throw new Error('should not be called'); + }; + let sendImageCalled = false; + const sendImage = async () => { sendImageCalled = true; }; + + const reply = await actionEditGraphic('phone-4', 'img_1', { background_color: 'red' }, { sendImage }); + + assert.match(reply, /can't edit background_color/); + assert.equal(sendImageCalled, false); +}); + +test('actionEditGraphic applies an allowed edit end-to-end: generates, polls, sends the thumbnail, and records the edit', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; + expressApi.generateVariation = async (docId, tagMappings, pages, preferredDocumentName) => { + assert.equal(docId, 'urn:doc:1'); + assert.deepEqual(tagMappings, { cta: '20% off' }); + assert.equal(pages, '1'); + assert.match(preferredDocumentName, /^Croma Earbuds-edit-\d+$/); + return { jobId: 'job-1', statusUrl: 'https://express-api.adobe.io/status/job-1' }; + }; + expressApi.pollJobStatus = async (jobId) => { + assert.equal(jobId, 'job-1'); + return { status: 'succeeded', document: { thumbnailUrl: 'https://example.com/thumb.png' } }; + }; + + const sentCalls = []; + const sendImage = async (to, link) => { sentCalls.push({ to, link }); }; + + const reply = await actionEditGraphic('phone-5', 'img_1', { cta: '20% off' }, { sendImage }); + + assert.match(reply, /Updated "Croma Earbuds"/); + assert.equal(sentCalls.length, 1); + assert.equal(sentCalls[0].to, 'phone-5'); + assert.equal(sentCalls[0].link, 'https://example.com/thumb.png'); + + const image = findTrackedImage('phone-5', 'img_1'); + assert.deepEqual(image.currentEdits, { cta: '20% off' }); +}); + +test('actionEditGraphic returns a friendly message and does not record the edit when generation fails', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; + expressApi.generateVariation = async () => { throw new Error('generateVariation failed 500: boom'); }; + + let sendImageCalled = false; + const sendImage = async () => { sendImageCalled = true; }; + + const reply = await actionEditGraphic('phone-6', 'img_1', { cta: '20% off' }, { sendImage }); + + assert.match(reply, /something went wrong generating/); + assert.equal(sendImageCalled, false); + + const image = findTrackedImage('phone-6', 'img_1'); + assert.deepEqual(image.currentEdits, {}); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `node --test actions.test.js` +Expected: FAIL — old `actions.js` still validates against `getTemplateInfo`/hardcoded layers and calls `metaUpload`, not matching these assertions + +- [ ] **Step 3: Write the implementation** + +Replace the contents of `actions.js`: + +```js +const { getTrackedImages, findTrackedImage, recordEdits } = require('./imageStore'); +const expressApi = require('./expressApi'); + +function formatUnknownImageMessage(phoneNumber) { + const images = getTrackedImages(phoneNumber); + const list = images.map((image) => `- ${image.name}`).join('\n'); + return `I couldn't find that image. Here's what I have:\n${list}`; +} + +async function actionListCampaignGraphics() { + // TODO: fetch from campaign API + return 'Graphics in your current campaign:\n1. Diwali Offer Banner\n2. Summer Sale Flyer\n3. Croma Earbuds'; +} + +async function actionCheckAllowedEdits(phoneNumber, imageId) { + const image = findTrackedImage(phoneNumber, imageId); + if (!image) { + return formatUnknownImageMessage(phoneNumber); + } + + try { + const doc = await expressApi.getTaggedDocument(image.docId); + const elements = expressApi.collectTaggedElements(doc); + return expressApi.formatAllowedEdits(image.name, elements); + } catch (err) { + console.error('[actionCheckAllowedEdits] Express API error', { docId: image.docId, message: err.message }); + return `Sorry, I couldn't check the allowed edits for "${image.name}" right now. Please try again in a moment.`; + } +} + +async function actionEditGraphic(phoneNumber, imageId, edits, { sendImage }) { + const image = findTrackedImage(phoneNumber, imageId); + if (!image) { + return formatUnknownImageMessage(phoneNumber); + } + + let elements; + try { + const doc = await expressApi.getTaggedDocument(image.docId); + elements = expressApi.collectTaggedElements(doc); + } catch (err) { + console.error('[actionEditGraphic] Express API error', { docId: image.docId, message: err.message }); + return `Sorry, I couldn't reach Adobe Express to apply that edit. Please try again in a moment.`; + } + + const allowedNames = elements.map((element) => element.name); + const requestedKeys = Object.keys(edits || {}); + const disallowedKeys = requestedKeys.filter((key) => !allowedNames.includes(key)); + + if (disallowedKeys.length > 0) { + return `I can't edit ${disallowedKeys.join(', ')} on "${image.name}". ${expressApi.formatAllowedEdits(image.name, elements)}`; + } + + const mergedEdits = { ...image.currentEdits, ...edits }; + const pages = expressApi.pagesForEdits(elements, Object.keys(mergedEdits)); + const preferredDocumentName = expressApi.buildPreferredDocumentName(image.name); + + let thumbnailUrl; + try { + const { jobId } = await expressApi.generateVariation(image.docId, mergedEdits, pages, preferredDocumentName); + const result = await expressApi.pollJobStatus(jobId); + thumbnailUrl = result.document.thumbnailUrl; + } catch (err) { + console.error('[actionEditGraphic] generate/poll error', { docId: image.docId, message: err.message }); + return `Sorry, something went wrong generating your updated "${image.name}". Please try again.`; + } + + recordEdits(phoneNumber, imageId, edits); + await sendImage(phoneNumber, thumbnailUrl); + + const summary = Object.entries(edits).map(([key, value]) => `• ${key}: ${value}`).join('\n'); + return `Updated "${image.name}":\n${summary}`; +} + +async function actionGenerateBulkGraphics(filename) { + // TODO: parse CSV/Excel and call Adobe Express API per row + return `Bulk generation complete! Graphics created from ${filename || 'your uploaded file'}.`; +} + +module.exports = { + actionListCampaignGraphics, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, +}; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test actions.test.js` +Expected: PASS — 6 tests passing + +- [ ] **Step 5: Run the full test suite** + +Run: `node --test` +Expected: PASS — all tests across `expressAuth.test.js`, `imageStore.test.js`, `expressApi.test.js`, `actions.test.js`, `metaUpload.test.js` passing + +- [ ] **Step 6: Commit** + +```bash +git add actions.js actions.test.js +git commit -m "feat: wire actions.js to the real Express API and drop mocked edit validation" +``` + +--- + +## Task 5: `app.js` webhook logging + `render.yaml` env vars + +**Files:** +- Modify: `app.js:197-202` (incoming message loop) +- Modify: `render.yaml` (envVars list) + +**Interfaces:** +- Consumes: nothing new — pure logging addition, no new imports. +- Produces: nothing new — verified manually (webhook route, no unit test, matching the existing pattern for `app.js`'s WhatsApp/webhook wiring). + +- [ ] **Step 1: Add labeled webhook logging** + +In `app.js`, inside the `for (const message of messages) {` loop in the `app.post('/')` handler, replace: + +```js + for (const message of messages) { + const userText = message?.text?.body; + if (!userText) continue; +``` + +with: + +```js + for (const message of messages) { + if (message.context) console.log('[webhook] message.context:', JSON.stringify(message.context)); + if (message.referral) console.log('[webhook] message.referral:', JSON.stringify(message.referral)); + if (message.image) console.log('[webhook] message.image:', JSON.stringify(message.image)); + + const userText = message?.text?.body; + if (!userText) continue; +``` + +- [ ] **Step 2: Add the new env vars to `render.yaml`** + +In `render.yaml`, add to the `envVars` list (after the existing `OPENAI_MODEL` entry): + +```yaml + - key: EXPRESS_CLIENT_ID + sync: false + - key: EXPRESS_CLIENT_SECRET + sync: false + - key: EXPRESS_API_SCOPE + sync: false + - key: EXPRESS_IMS_TOKEN_URL + sync: false + - key: EXPRESS_API_BASE_URL + sync: false + - key: EXPRESS_TEMPLATES_FILE + sync: false + - key: EXPRESS_STATUS_POLL_INTERVAL_MS + sync: false + - key: EXPRESS_STATUS_POLL_TIMEOUT_MS + sync: false +``` + +- [ ] **Step 3: Verify the file is syntactically valid** + +Run: `node --check app.js` +Expected: no output (silent success) + +- [ ] **Step 4: Verify the server still boots and the webhook-verification route still works** + +Run: + +```bash +VERIFY_TOKEN=test WHATSAPP_PHONE_NUMBER_ID=123 WHATSAPP_TOKEN=test OPENAI_API_KEY=test EXPRESS_CLIENT_ID=test EXPRESS_CLIENT_SECRET=test node app.js & +SERVER_PID=$! +sleep 1 +curl -s "http://localhost:3000/?hub.mode=subscribe&hub.verify_token=test&hub.challenge=hello123" +kill $SERVER_PID +``` + +Expected: `hello123` printed by curl, followed by `WEBHOOK VERIFIED` in the server's stdout before it's killed. + +- [ ] **Step 5: Run the full test suite once more to confirm nothing broke** + +Run: `node --test` +Expected: PASS — all tests passing (same count as end of Task 4) + +- [ ] **Step 6: Commit** + +```bash +git add app.js render.yaml +git commit -m "feat: log webhook metadata fields and declare Express API env vars" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** IMS auth (Task 1), real `tagged-documents`/`generate-variation`/`status` calls (Task 3), shared `id`/`name`/`docId` catalog replacing hardcoded seed images (Task 2), simplified allowed-edits messaging and real-field validation (Tasks 3–4), generate→poll→send flow with `thumbnailUrl` sent directly (Task 4), `metaUpload.js` left in place and unused (Task 4, no import added), webhook metadata logging for docID investigation (Task 5), env var declarations (Task 5) — all covered. `actionListCampaignGraphics`/`actionGenerateBulkGraphics` and any new logic on `context`/`referral`/`image` fields are explicitly out of scope per the spec and untouched beyond logging. +- **Placeholder scan:** no TBDs; the two `// TODO: fetch from campaign API` / `// TODO: parse CSV/Excel...` comments are carried over unchanged from existing code (explicitly out of scope) and are intentional. +- **Type consistency:** `image.docId` (not `templateId`) used consistently across `imageStore.js` (Task 2), `expressApi.js` functions (Task 3), and `actions.js` (Task 4). `currentEdits` shape (`{ [tagName]: value }`) consistent between `imageStore.js`'s `recordEdits`/`getTrackedImages` and `actions.js`'s `mergedEdits` construction. `sendImage(to, link)` signature unchanged from the existing `app.js` implementation, matching its call site in Task 4. `expressApi` is a namespace import in `actions.js` (not destructured) specifically so `actions.test.js` can stub individual functions — noted in Task 4's Interfaces block so this doesn't look like an inconsistency with Task 3's plain destructure inside `expressApi.js` itself (that destructure is fine since `expressApi.test.js` stubs `global.fetch`, not `expressAuth`'s exports). diff --git a/docs/superpowers/specs/2026-07-13-global-fixed-edit-response-design.md b/docs/superpowers/specs/2026-07-13-global-fixed-edit-response-design.md new file mode 100644 index 0000000000..afcdc7cabb --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-global-fixed-edit-response-design.md @@ -0,0 +1,52 @@ +# Design: Global fixed responses for edit-graphic and check-allowed-edits + +Amends [2026-07-13-express-edit-flow-design.md](./2026-07-13-express-edit-flow-design.md). + +## Goal + +Simplify two of the existing edit-flow actions in `actions.js` so their outputs are fixed, regardless of which image/template the request is about: + +1. **`edit_graphic`** — after any successful edit, always send the same hardcoded image (`https://s7ap1.scene7.com/is/image/varun/croma1-earbuds-updated`) as a new WhatsApp image message to the requesting user. No more per-template mock render or Meta upload. +2. **`check_allowed_edits`** — always reply with the fixed text `Price, Partner logo, Partner's Address, product image`, regardless of which image is asked about. + +As a consequence, the "is this edit key allowed for this template" validation in `actionEditGraphic` is removed: any edit key is now accepted for any image. + +## Scope + +Only `actions.js` changes. `expressApi.js`, `imageStore.js`, `metaUpload.js`, and `app.js` are untouched — `expressApi.js` and `metaUpload.js` become unused by application code (no longer called from `actions.js`) but are left in place, along with their existing tests, as placeholders for a future real Adobe Express / Meta upload integration. + +## Changes to `actions.js` + +Two new constants: + +```js +const EDITED_IMAGE_URL = 'https://s7ap1.scene7.com/is/image/varun/croma1-earbuds-updated'; +const ALLOWED_EDITS_TEXT = "Price, Partner logo, Partner's Address, product image"; +``` + +`actionCheckAllowedEdits(phoneNumber, imageId)`: +- Keeps the unknown-image guard (`formatUnknownImageMessage`). +- For any known image, returns `ALLOWED_EDITS_TEXT` unconditionally — no more per-template `getTemplateInfo` lookup. + +`actionEditGraphic(phoneNumber, imageId, edits, { sendImage })`: +- Keeps the unknown-image guard. +- Drops the disallowed-key validation entirely (no `getTemplateInfo` call, no rejection path). +- Merges `edits` into `image.currentEdits` directly (`{ ...image.currentEdits, ...edits }`) instead of calling `expressApi.applyEdit` — the rendered URL it used to return is no longer needed since we always send `EDITED_IMAGE_URL`. +- Sends `EDITED_IMAGE_URL` via `sendImage` (no more `metaUpload.uploadImageToMeta` call, since this URL is already publicly hosted). +- Returns the same `Updated "":\n` confirmation text as before. + +Unused imports (`getTemplateInfo`, `applyEdit` from `expressApi.js`; `uploadImageToMeta` from `metaUpload.js`) are removed from `actions.js`. + +## Error handling + +Unchanged from the existing flow: an unknown `image_id` (GPT hallucination) still gets the friendly "I couldn't find that image" message in both actions, with no crash. There is no other error path left in `actionEditGraphic` now that key validation is removed. + +## Testing + +Update `actions.test.js`: +- `actionCheckAllowedEdits` tests for known images (Diwali, Croma) now assert the reply equals `ALLOWED_EDITS_TEXT`, for any tracked image — not a per-template layer list. +- The "rejects edits outside the unlocked layers" test is replaced with a test confirming a previously-disallowed key (e.g. `background_color` on the Croma image) is now accepted and sends the image. +- The edit tests (Croma image, Summer Sale Flyer image) both assert `sendImage` is called with `EDITED_IMAGE_URL`, for every template — not just Croma. +- The "remembers the edit" assertion (`image.currentEdits`) is unchanged. + +`expressApi.test.js` and `metaUpload.test.js` are untouched — those modules' own behavior isn't changing, only their (lack of) callers. diff --git a/docs/superpowers/specs/2026-07-16-real-express-api-integration-design.md b/docs/superpowers/specs/2026-07-16-real-express-api-integration-design.md new file mode 100644 index 0000000000..5505b2d7f0 --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-real-express-api-integration-design.md @@ -0,0 +1,188 @@ +# Design: Real Adobe Express API integration for the image-edit flow + +Amends [2026-07-13-express-edit-flow-design.md](./2026-07-13-express-edit-flow-design.md) and [2026-07-13-global-fixed-edit-response-design.md](./2026-07-13-global-fixed-edit-response-design.md), replacing the mocked/fixed behavior those introduced with real Adobe Express API calls. + +## Goal + +Replace all hardcoding and mocked responses in the image-edit flow with real calls to the Adobe Express API: + +1. `GET /beta/tagged-documents/` to discover what's editable on a graphic and show the user a simplified list. +2. `POST /beta/generate-variation` to apply the user's requested edits. +3. `GET /status/` to poll until the variation is ready. +4. Send the resulting `thumbnailUrl` back to the user as a new WhatsApp image message. + +The identity of *which* Express document a tracked image maps to comes from a JSON catalog shared with the separate UI repo (out of scope for this change), not from anything invented in this backend. + +## Shared catalog: `data/express-templates.json` + +Committed to this repo (not gitignored), maintained in step with an equivalent file in the UI repo: + +```json +[ + { "id": "img_1", "name": "Diwali Offer Banner", "docId": "urn:aaid:sc:AP:..." }, + { "id": "img_2", "name": "Summer Sale Flyer", "docId": "urn:aaid:sc:AP:..." }, + { "id": "img_3", "name": "Croma Earbuds", "docId": "urn:aaid:sc:AP:aaed427c-b4e4-55e4-b924-74d375f91684" } +] +``` + +- Flat array, `id`/`name`/`docId` only — no `phoneNumber`. It's a global catalog of known templates/images, not a per-customer sent-images log (the UI app's `sendWhatsAppTemplateMessage` already carries `docId` alongside `templateName` on its own send calls; this file is the read side this backend needs). +- Path is overridable via `EXPRESS_TEMPLATES_FILE` env var, defaulting to `data/express-templates.json`. +- Keeping both repos' copies of this file in sync (git submodule, manual copy, CI step, etc.) is out of scope for this change — flagged, not solved, here. + +## New module: `expressAuth.js` + +```js +async function getAccessToken() // returns a cached IMS access token, refreshing near expiry +async function buildAuthHeaders() // -> { Authorization: 'Bearer ', 'X-API-KEY': } +``` + +- `POST https://ims-na1.adobelogin.com/ims/token/v3` (overridable via `EXPRESS_IMS_TOKEN_URL`), `grant_type=client_credentials`, `client_id`/`client_secret` from `EXPRESS_CLIENT_ID`/`EXPRESS_CLIENT_SECRET`, `scope` from `EXPRESS_API_SCOPE` (default `ee.express_api,openid,AdobeID,read_organizations,additional_info.projectedProductContext`, taken from a decoded sample token). +- `X-API-KEY` is `EXPRESS_CLIENT_ID` (confirmed identical to the sample token's `client_id` claim). +- Token cached in memory with its expiry; refetched once within 60s of expiry. +- **Assumption to verify on first real call:** Adobe IMS v3 token responses have historically returned `expires_in` in **milliseconds**, not seconds (unlike v2). This module treats `expires_in` as milliseconds directly (`expiresAt = Date.now() + Number(expires_in)`). If the real response turns out to be in seconds, this is a one-line fix isolated to this function. + +## Rewritten module: `expressApi.js` + +No more `TEMPLATE_LAYERS`/mock render. Real HTTP calls against `EXPRESS_API_BASE_URL` (default `https://express-api.adobe.io`), using `expressAuth.buildAuthHeaders()`: + +```js +async function getTaggedDocument(docId) +// GET /beta/tagged-documents/ +// -> { name, id, documentPages: [{ pageNumber, taggedElements: [{ name, type, value?, position, size }] }] } + +async function generateVariation(docId, tagMappings, pages, preferredDocumentName) +// POST /beta/generate-variation +// body: { id: docId, variationDetails: { pages, preferredDocumentName, tagMappings } } +// -> { jobId, statusUrl } + +async function getJobStatus(jobId) +// GET /status/ +// -> { jobId, status, document?: { name, id, thumbnailUrl } } + +async function pollJobStatus(jobId, { intervalMs, timeoutMs } = {}) +// Polls getJobStatus every intervalMs (default EXPRESS_STATUS_POLL_INTERVAL_MS=2000) +// until status === 'succeeded' (returns the full result) or 'failed' (throws), +// or until timeoutMs elapses (default EXPRESS_STATUS_POLL_TIMEOUT_MS=60000, throws). +``` + +Non-2xx responses from any of the three GET/POST calls throw with the HTTP status and response body included in the error message, for logging by the caller. + +### Helpers (co-located in `expressApi.js`) + +```js +function collectTaggedElements(taggedDocument) +// Flattens documentPages[].taggedElements[] into one array, each tagged with its pageNumber. + +function formatAllowedEdits(name, elements) +// Simplified user-facing text, e.g.: +// Edits allowed on "Croma Earbuds": +// - heading: currently "The X-Phone Pro is here!" +// - cta: currently "Available at our store starting 15 Aug 20XX." +// Tell me what you'd like to change and to what, e.g. "change cta to ...". +// Non-text element types are listed as "- ()" without a current value. + +function pagesForEdits(elements, editKeys) +// -> comma-joined, ascending page numbers whose taggedElements include any of editKeys, e.g. "1" or "1,2". + +function buildPreferredDocumentName(baseName) +// -> `${baseName}-edit-${Date.now()}` +``` + +## Rewritten module: `imageStore.js` + +```js +function getTrackedImages(phoneNumber) +// Reads data/express-templates.json fresh on every call (no caching of the catalog itself), +// merges in this conversation's accumulated tagMappings. +// -> Array<{ id, name, docId, currentEdits }> + +function findTrackedImage(phoneNumber, imageId) +// -> single entry from getTrackedImages(phoneNumber), or undefined + +function recordEdits(phoneNumber, imageId, newEdits) +// Merges newEdits into the in-memory Map<"phone:id", tagMappings> and returns the merged object. +// Only called after a generate-variation call succeeds (see actions.js below) — +// a failed/timed-out edit does not get committed, so a retry starts from the last-known-good state. +``` + +Catalog read failures (missing/invalid file) are logged and treated as an empty catalog (no images tracked) rather than crashing the process. + +## `metaUpload.js` + +Left in place, untouched, exports unchanged — but no longer imported by `actions.js`. `generate-variation`'s `status` response already returns a public, directly-fetchable `thumbnailUrl`, so there's no upload hop needed. `metaUpload.test.js` stays as-is since the module's own behavior isn't changing. + +## Rewritten `actions.js` + +```js +async function actionCheckAllowedEdits(phoneNumber, imageId) { + // unknown-image guard unchanged (formatUnknownImageMessage) + // getTaggedDocument(image.docId) -> collectTaggedElements -> formatAllowedEdits(image.name, elements) + // Express API errors: log technical detail, return a generic friendly retry message (no crash, no raw error to the user) +} + +async function actionEditGraphic(phoneNumber, imageId, edits, { sendImage }) { + // unknown-image guard unchanged + // getTaggedDocument(image.docId) -> collectTaggedElements -> allowedNames + // requestedKeys not in allowedNames -> same "I can't edit X, allowed edits are: " rejection as before, no Express/generate call made + // mergedEdits = { ...image.currentEdits, ...edits } + // pages = pagesForEdits(elements, Object.keys(mergedEdits)) + // preferredDocumentName = buildPreferredDocumentName(image.name) + // generateVariation(image.docId, mergedEdits, pages, preferredDocumentName) -> jobId + // pollJobStatus(jobId) -> result + // recordEdits(phoneNumber, imageId, edits) // only on success + // sendImage(phoneNumber, result.document.thumbnailUrl) + // generate/poll errors: log technical detail, return a generic friendly retry message + // Returns the same "Updated \"\":\n" confirmation text as before on success +} +``` + +`actionListCampaignGraphics` and `actionGenerateBulkGraphics` are unchanged (still out of scope — their existing `// TODO` mocks stay as-is). + +## `app.js` — webhook logging only + +No behavior change to the GPT tool flow. In the incoming-message loop, before the existing `if (!userText) continue`, add labeled logging for fields that might carry the UI app's per-message metadata, so a real test send lets you find where (if anywhere) a docID rides along in this app's webhook payload, independent of the `data/express-templates.json` catalog: + +```js +if (message.context) console.log('[webhook] message.context:', JSON.stringify(message.context)); +if (message.referral) console.log('[webhook] message.referral:', JSON.stringify(message.referral)); +if (message.image) console.log('[webhook] message.image:', JSON.stringify(message.image)); +``` + +This is observability only — no new logic acts on these fields. Actually handling incoming image messages (or any other message type beyond text) stays out of scope, same as the original design. + +## Environment variables + +New, added to `render.yaml` (`sync: false` for secrets, matching existing `OPENAI_API_KEY` pattern): + +- `EXPRESS_CLIENT_ID` (required, secret) +- `EXPRESS_CLIENT_SECRET` (required, secret) +- `EXPRESS_API_SCOPE` (optional, has default) +- `EXPRESS_IMS_TOKEN_URL` (optional, has default) +- `EXPRESS_API_BASE_URL` (optional, has default) +- `EXPRESS_TEMPLATES_FILE` (optional, has default) +- `EXPRESS_STATUS_POLL_INTERVAL_MS` (optional, has default) +- `EXPRESS_STATUS_POLL_TIMEOUT_MS` (optional, has default) + +## Error handling + +- Unknown `image_id` (GPT hallucination): unchanged friendly message, no crash. +- Disallowed edit field(s): unchanged rejection listing what's actually allowed, no Express calls made — now driven by real `taggedElements` instead of hardcoded layers. +- Any Adobe Express HTTP error (auth failure, 404 doc not found, network error), and any poll timeout/failure: caught in `actions.js`, logged with technical detail (status, docId/jobId, message), and surfaced to the user as a short generic retry message — never a raw stack trace or API error body over WhatsApp. +- A failed/timed-out edit does not get merged into the conversation's `tagMappings`, so retrying re-sends the last-known-good edit set plus the new attempt, rather than compounding a bad state. + +## Testing + +No new npm dependencies — tests stub `global.fetch` directly (Node's built-in `node:test`, matching the existing pattern; `app.js` already relies on global `fetch` for WhatsApp calls). + +- `expressAuth.test.js` (new): fetches and caches a token; refetches once near-expiry; builds the right headers. +- `expressApi.test.js` (rewritten): `getTaggedDocument`/`generateVariation`/`getJobStatus` send the right URL/method/body/headers and parse responses correctly; `pollJobStatus` resolves on `succeeded`, throws on `failed`, throws on timeout without exceeding it; `collectTaggedElements`/`formatAllowedEdits`/`pagesForEdits`/`buildPreferredDocumentName` unit-tested directly against sample API response shapes. +- `imageStore.test.js` (rewritten): reads a fixture catalog file; `recordEdits` accumulates correctly per `(phoneNumber, imageId)`; missing/invalid catalog file degrades to an empty list without throwing. +- `actions.test.js` (rewritten): known-image allowed-edits listing; unknown-image guard; disallowed-field rejection (no generate call); happy path (generate → poll → `sendImage` called with `thumbnailUrl`, edits recorded); generate/poll failure path (friendly message, `sendImage` not called, edits not recorded). +- `metaUpload.test.js`: untouched. + +## Out of scope + +- Keeping the two repos' `data/express-templates.json` copies in sync. +- Any new logic acting on `message.context`/`message.referral`/`message.image` beyond logging them. +- `actionListCampaignGraphics` / `actionGenerateBulkGraphics` real implementations. +- Handling of non-text incoming WhatsApp message types beyond the added logging. diff --git a/expressApi.js b/expressApi.js deleted file mode 100644 index 0a352c054d..0000000000 --- a/expressApi.js +++ /dev/null @@ -1,33 +0,0 @@ -const TEMPLATE_LAYERS = { - tpl_diwali: ['discount_text', 'headline', 'background_color'], - tpl_summer: ['headline', 'font_color'], - tpl_croma_earbuds: ['Price', 'Address', 'Product Image', 'Partner Logo'], -}; - -function getTemplateInfo(templateId) { - return { - templateId, - unlockedLayers: TEMPLATE_LAYERS[templateId] || [], - }; -} - -let renderRevision = 0; - -function applyEdit(templateId, currentEdits, newEdits) { - const mergedEdits = { ...currentEdits, ...newEdits }; - - if (templateId === 'tpl_croma_earbuds') { - return { - mergedEdits, - renderedImageUrl: 'https://s7ap1.scene7.com/is/image/varun/croma1-earbuds-updated', - }; - } - - renderRevision += 1; - return { - mergedEdits, - renderedImageUrl: `https://mock-express.local/render/${templateId}?rev=${renderRevision}`, - }; -} - -module.exports = { getTemplateInfo, applyEdit }; diff --git a/expressApi.test.js b/expressApi.test.js deleted file mode 100644 index c9b3126535..0000000000 --- a/expressApi.test.js +++ /dev/null @@ -1,39 +0,0 @@ -const test = require('node:test'); -const assert = require('node:assert/strict'); -const { getTemplateInfo, applyEdit } = require('./expressApi'); - -test('getTemplateInfo returns the unlocked layers for a known template', () => { - const info = getTemplateInfo('tpl_diwali'); - assert.deepEqual(info, { - templateId: 'tpl_diwali', - unlockedLayers: ['discount_text', 'headline', 'background_color'], - }); -}); - -test('getTemplateInfo returns the unlocked layers for the Croma earbuds template', () => { - const info = getTemplateInfo('tpl_croma_earbuds'); - assert.deepEqual(info, { - templateId: 'tpl_croma_earbuds', - unlockedLayers: ['Price', 'Address', 'Product Image', 'Partner Logo'], - }); -}); - -test('getTemplateInfo returns an empty layer list for an unknown template', () => { - const info = getTemplateInfo('tpl_does_not_exist'); - assert.deepEqual(info.unlockedLayers, []); -}); - -test('applyEdit merges new edits on top of current edits', () => { - const result = applyEdit('tpl_diwali', { headline: 'Old Headline' }, { discount_text: '70%' }); - assert.deepEqual(result.mergedEdits, { headline: 'Old Headline', discount_text: '70%' }); -}); - -test('applyEdit returns a rendered image url that references the template', () => { - const result = applyEdit('tpl_summer', {}, { headline: 'Flash Sale' }); - assert.match(result.renderedImageUrl, /^https:\/\/mock-express\.local\/render\/tpl_summer\?rev=\d+$/); -}); - -test('applyEdit returns the fixed updated Croma earbuds image for any edit', () => { - const result = applyEdit('tpl_croma_earbuds', {}, { Price: '999' }); - assert.equal(result.renderedImageUrl, 'https://s7ap1.scene7.com/is/image/varun/croma1-earbuds-updated'); -}); diff --git a/imageStore.js b/imageStore.js deleted file mode 100644 index 136d365bc8..0000000000 --- a/imageStore.js +++ /dev/null @@ -1,28 +0,0 @@ -const SEED_IMAGES = [ - { id: 'img_1', name: 'Diwali Offer Banner', templateId: 'tpl_diwali' }, - { id: 'img_2', name: 'Summer Sale Flyer', templateId: 'tpl_summer' }, - { - id: 'img_3', - name: 'Croma Earbuds', - templateId: 'tpl_croma_earbuds', - url: 'https://s7ap1.scene7.com/is/image/varun/croma1-earbuds', - }, -]; - -const trackedImages = new Map(); - -function getTrackedImages(phoneNumber) { - if (!trackedImages.has(phoneNumber)) { - trackedImages.set( - phoneNumber, - SEED_IMAGES.map((image) => ({ ...image, currentEdits: {} })) - ); - } - return trackedImages.get(phoneNumber); -} - -function findTrackedImage(phoneNumber, imageId) { - return getTrackedImages(phoneNumber).find((image) => image.id === imageId); -} - -module.exports = { getTrackedImages, findTrackedImage }; diff --git a/imageStore.test.js b/imageStore.test.js deleted file mode 100644 index 841acf9d78..0000000000 --- a/imageStore.test.js +++ /dev/null @@ -1,35 +0,0 @@ -const test = require('node:test'); -const assert = require('node:assert/strict'); -const { getTrackedImages, findTrackedImage } = require('./imageStore'); - -test('getTrackedImages seeds 3 images on first access', () => { - const images = getTrackedImages('111'); - assert.equal(images.length, 3); - assert.deepEqual(images.map((img) => img.id), ['img_1', 'img_2', 'img_3']); - assert.deepEqual(images[0].currentEdits, {}); -}); - -test('getTrackedImages returns the same array on repeated calls for the same phone number', () => { - const first = getTrackedImages('222'); - first[0].currentEdits.headline = 'Flash Sale'; - const second = getTrackedImages('222'); - assert.equal(second[0].currentEdits.headline, 'Flash Sale'); -}); - -test('getTrackedImages seeds independently per phone number', () => { - getTrackedImages('333')[0].currentEdits.headline = 'Only for 333'; - const other = getTrackedImages('444'); - assert.deepEqual(other[0].currentEdits, {}); -}); - -test('findTrackedImage returns the matching image', () => { - getTrackedImages('555'); - const image = findTrackedImage('555', 'img_2'); - assert.equal(image.name, 'Summer Sale Flyer'); -}); - -test('findTrackedImage returns undefined for an unknown id', () => { - getTrackedImages('666'); - const image = findTrackedImage('666', 'img_999'); - assert.equal(image, undefined); -}); diff --git a/package.json b/package.json index 854ad81ab5..f68ca9be99 100644 --- a/package.json +++ b/package.json @@ -2,13 +2,13 @@ "name": "express-hello-world", "version": "1.0.0", "description": "Express Hello World on Render", - "main": "app.js", + "main": "src/app.js", "repository": "https://github.com/render-examples/express-hello-world", "author": "Render Developers", "license": "MIT", "private": false, "scripts": { - "start": "node app.js", + "start": "node src/app.js", "test": "node --test" }, "dependencies": { diff --git a/render.yaml b/render.yaml index 55fe1154ed..869c1badac 100644 --- a/render.yaml +++ b/render.yaml @@ -4,7 +4,7 @@ services: runtime: node plan: free buildCommand: yarn install --frozen-lockfile - startCommand: node app.js + startCommand: node src/app.js envVars: - key: NODE_ENV value: production @@ -20,3 +20,19 @@ services: sync: false - key: OPENAI_MODEL sync: false + - key: EXPRESS_CLIENT_ID + sync: false + - key: EXPRESS_CLIENT_SECRET + sync: false + - key: EXPRESS_API_SCOPE + sync: false + - key: EXPRESS_IMS_TOKEN_URL + sync: false + - key: EXPRESS_API_BASE_URL + sync: false + - key: EXPRESS_TEMPLATES_FILE + sync: false + - key: EXPRESS_STATUS_POLL_INTERVAL_MS + sync: false + - key: EXPRESS_STATUS_POLL_TIMEOUT_MS + sync: false diff --git a/src/actions.js b/src/actions.js new file mode 100644 index 0000000000..9c6e29fa46 --- /dev/null +++ b/src/actions.js @@ -0,0 +1,100 @@ +const { getTrackedImages, findTrackedImage, recordEdits } = require('./imageStore'); +const expressApi = require('./express/expressApi'); + +function formatUnknownImageMessage(phoneNumber) { + const images = getTrackedImages(phoneNumber); + const list = images.map((image) => `- ${image.name}`).join('\n'); + return `I couldn't find that image. Here's what I have:\n${list}`; +} + +async function actionListCampaignGraphics() { + // TODO: fetch from campaign API + return 'Graphics in your current campaign:\n1. Croma Earbuds'; +} + +function withCurrentEdits(elements, currentEdits) { + return elements.map((element) => + element.name in currentEdits ? { ...element, value: currentEdits[element.name] } : element + ); +} + +async function actionCheckAllowedEdits(phoneNumber, imageId) { + const image = findTrackedImage(phoneNumber, imageId); + if (!image) { + return formatUnknownImageMessage(phoneNumber); + } + + try { + const doc = await expressApi.getTaggedDocument(image.docId); + const elements = expressApi.collectTaggedElements(doc); + const elementsWithCurrentEdits = withCurrentEdits(elements, image.currentEdits); + return expressApi.formatAllowedEdits(image.name, elementsWithCurrentEdits); + } catch (err) { + console.error('[actionCheckAllowedEdits] Express API error', { docId: image.docId, message: err.message }); + return `Sorry, I couldn't check the allowed edits for "${image.name}" right now. Please try again in a moment.`; + } +} + +async function actionEditGraphic(phoneNumber, imageId, edits, { sendImage }) { + const image = findTrackedImage(phoneNumber, imageId); + if (!image) { + return formatUnknownImageMessage(phoneNumber); + } + + let elements; + try { + const doc = await expressApi.getTaggedDocument(image.docId); + elements = expressApi.collectTaggedElements(doc); + } catch (err) { + console.error('[actionEditGraphic] Express API error', { docId: image.docId, message: err.message }); + return `Sorry, I couldn't reach Adobe Express to apply that edit. Please try again in a moment.`; + } + + const allowedNames = elements.map((element) => element.name); + const requestedKeys = Object.keys(edits || {}); + const disallowedKeys = requestedKeys.filter((key) => !allowedNames.includes(key)); + + if (disallowedKeys.length > 0) { + const elementsWithCurrentEdits = withCurrentEdits(elements, image.currentEdits); + return `I can't edit ${disallowedKeys.join(', ')} on "${image.name}". ${expressApi.formatAllowedEdits(image.name, elementsWithCurrentEdits)}`; + } + + const mergedEdits = { ...image.currentEdits, ...edits }; + const pages = expressApi.pagesForEdits(elements, Object.keys(mergedEdits)); + const preferredDocumentName = expressApi.buildPreferredDocumentName(image.name); + + let thumbnailUrl; + try { + const { statusUrl } = await expressApi.generateVariation(image.docId, mergedEdits, pages, preferredDocumentName); + const result = await expressApi.pollJobStatus(statusUrl); + thumbnailUrl = result.document.thumbnailUrl; + } catch (err) { + console.error('[actionEditGraphic] generate/poll error', { docId: image.docId, message: err.message }); + return `Sorry, something went wrong generating your updated "${image.name}". Please try again.`; + } + + recordEdits(phoneNumber, imageId, edits); + + const summary = Object.entries(edits).map(([key, value]) => `• ${key}: ${value}`).join('\n'); + + try { + await sendImage(phoneNumber, thumbnailUrl); + } catch (err) { + console.error('[actionEditGraphic] sendImage error', { docId: image.docId, message: err.message }); + return `Updated "${image.name}", but I couldn't send the image right now — try asking me to resend it.`; + } + + return `Updated "${image.name}":\n${summary}`; +} + +async function actionGenerateBulkGraphics(filename) { + // TODO: parse CSV/Excel and call Adobe Express API per row + return `Bulk generation complete! Graphics created from ${filename || 'your uploaded file'}.`; +} + +module.exports = { + actionListCampaignGraphics, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, +}; diff --git a/src/actions.test.js b/src/actions.test.js new file mode 100644 index 0000000000..a662e30598 --- /dev/null +++ b/src/actions.test.js @@ -0,0 +1,149 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { actionCheckAllowedEdits, actionEditGraphic } = require('./actions'); +const expressApi = require('./express/expressApi'); +const { findTrackedImage, recordEdits } = require('./imageStore'); + +function writeFixtureCatalog(entries) { + const fixturePath = path.join(os.tmpdir(), `express-templates-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + fs.writeFileSync(fixturePath, JSON.stringify(entries)); + process.env.EXPRESS_TEMPLATES_FILE = fixturePath; +} + +const SAMPLE_ELEMENTS_DOC = { + documentPages: [ + { + pageNumber: 1, + taggedElements: [ + { name: 'heading', type: 'text', value: 'The X-Phone Pro is here!' }, + { name: 'cta', type: 'text', value: 'Available at our store starting 15 Aug 20XX.' }, + ], + }, + ], +}; + +test('actionCheckAllowedEdits lists the tagged elements for a known image', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async (docId) => { + assert.equal(docId, 'urn:doc:1'); + return SAMPLE_ELEMENTS_DOC; + }; + + const reply = await actionCheckAllowedEdits('phone-1', 'img_1'); + + assert.match(reply, /Croma Earbuds/); + assert.match(reply, /heading: currently "The X-Phone Pro is here!"/); + assert.match(reply, /cta: currently/); +}); + +test('actionCheckAllowedEdits shows the latest edited value instead of the stale original document value', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; + recordEdits('phone-1b', 'img_1', { cta: '20% off' }); + + const reply = await actionCheckAllowedEdits('phone-1b', 'img_1'); + + assert.match(reply, /cta: currently "20% off"/); + assert.doesNotMatch(reply, /Available at our store starting 15 Aug 20XX\./); + assert.match(reply, /heading: currently "The X-Phone Pro is here!"/); +}); + +test('actionCheckAllowedEdits reports unknown images without throwing', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + + const reply = await actionCheckAllowedEdits('phone-2', 'img_nope'); + + assert.match(reply, /couldn't find that image/); +}); + +test('actionCheckAllowedEdits returns a friendly message when the Express API call fails', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async () => { + throw new Error('getTaggedDocument failed 500: boom'); + }; + + const reply = await actionCheckAllowedEdits('phone-3', 'img_1'); + + assert.match(reply, /couldn't check the allowed edits/); +}); + +test('actionEditGraphic rejects edits outside the tagged elements and makes no generate call', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; + expressApi.generateVariation = async () => { + throw new Error('should not be called'); + }; + let sendImageCalled = false; + const sendImage = async () => { sendImageCalled = true; }; + + const reply = await actionEditGraphic('phone-4', 'img_1', { background_color: 'red' }, { sendImage }); + + assert.match(reply, /can't edit background_color/); + assert.equal(sendImageCalled, false); +}); + +test('actionEditGraphic applies an allowed edit end-to-end: generates, polls, sends the thumbnail, and records the edit', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; + expressApi.generateVariation = async (docId, tagMappings, pages, preferredDocumentName) => { + assert.equal(docId, 'urn:doc:1'); + assert.deepEqual(tagMappings, { cta: '20% off' }); + assert.equal(pages, '1'); + assert.match(preferredDocumentName, /^Croma Earbuds-edit-\d+$/); + return { jobId: 'job-1', statusUrl: 'https://express-api.adobe.io/status/job-1' }; + }; + expressApi.pollJobStatus = async (statusUrl) => { + assert.equal(statusUrl, 'https://express-api.adobe.io/status/job-1'); + return { status: 'succeeded', document: { thumbnailUrl: 'https://example.com/thumb.png' } }; + }; + + const sentCalls = []; + const sendImage = async (to, link) => { sentCalls.push({ to, link }); }; + + const reply = await actionEditGraphic('phone-5', 'img_1', { cta: '20% off' }, { sendImage }); + + assert.match(reply, /Updated "Croma Earbuds"/); + assert.equal(sentCalls.length, 1); + assert.equal(sentCalls[0].to, 'phone-5'); + assert.equal(sentCalls[0].link, 'https://example.com/thumb.png'); + + const image = findTrackedImage('phone-5', 'img_1'); + assert.deepEqual(image.currentEdits, { cta: '20% off' }); +}); + +test('actionEditGraphic returns a friendly message and does not record the edit when generation fails', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; + expressApi.generateVariation = async () => { throw new Error('generateVariation failed 500: boom'); }; + + let sendImageCalled = false; + const sendImage = async () => { sendImageCalled = true; }; + + const reply = await actionEditGraphic('phone-6', 'img_1', { cta: '20% off' }, { sendImage }); + + assert.match(reply, /something went wrong generating/); + assert.equal(sendImageCalled, false); + + const image = findTrackedImage('phone-6', 'img_1'); + assert.deepEqual(image.currentEdits, {}); +}); + +test('actionEditGraphic tells the user delivery failed but keeps the recorded edit when sendImage throws', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; + expressApi.generateVariation = async () => ({ jobId: 'job-1', statusUrl: 'https://express-api.adobe.io/status/job-1' }); + expressApi.pollJobStatus = async () => ({ status: 'succeeded', document: { thumbnailUrl: 'https://example.com/thumb.png' } }); + + const sendImage = async () => { throw new Error('WhatsApp could not fetch the link'); }; + + const reply = await actionEditGraphic('phone-7', 'img_1', { cta: '20% off' }, { sendImage }); + + assert.match(reply, /couldn't send the image right now/); + assert.doesNotMatch(reply, /something went wrong generating/); + + const image = findTrackedImage('phone-7', 'img_1'); + assert.deepEqual(image.currentEdits, { cta: '20% off' }); +}); diff --git a/app.js b/src/app.js similarity index 96% rename from app.js rename to src/app.js index 1d703d50e1..1baa2ef9d1 100644 --- a/app.js +++ b/src/app.js @@ -195,6 +195,10 @@ app.post('/', async (req, res) => { if (!messages?.length) return; for (const message of messages) { + if (message.context) console.log('[webhook] message.context:', JSON.stringify(message.context)); + if (message.referral) console.log('[webhook] message.referral:', JSON.stringify(message.referral)); + if (message.image) console.log('[webhook] message.image:', JSON.stringify(message.image)); + const userText = message?.text?.body; if (!userText) continue; diff --git a/src/express/expressApi.js b/src/express/expressApi.js new file mode 100644 index 0000000000..2be744773e --- /dev/null +++ b/src/express/expressApi.js @@ -0,0 +1,102 @@ +const { buildAuthHeaders } = require('./expressAuth'); + +function apiBaseUrl() { + return process.env.EXPRESS_API_BASE_URL || 'https://express-api.adobe.io'; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function getTaggedDocument(docId) { + const headers = await buildAuthHeaders(); + const response = await fetch(`${apiBaseUrl()}/beta/tagged-documents/${encodeURIComponent(docId)}`, { headers }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`getTaggedDocument failed ${response.status}: ${text}`); + } + return response.json(); +} + +async function generateVariation(docId, tagMappings, pages, preferredDocumentName) { + const headers = await buildAuthHeaders(); + const response = await fetch(`${apiBaseUrl()}/beta/generate-variation`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: docId, + variationDetails: { pages, preferredDocumentName, tagMappings }, + }), + }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`generateVariation failed ${response.status}: ${text}`); + } + return response.json(); +} + +async function getJobStatus(statusUrl) { + const headers = await buildAuthHeaders(); + const response = await fetch(statusUrl, { headers }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`getJobStatus failed ${response.status}: ${text}`); + } + return response.json(); +} + +async function pollJobStatus(statusUrl, { intervalMs, timeoutMs } = {}) { + const interval = intervalMs ?? Number(process.env.EXPRESS_STATUS_POLL_INTERVAL_MS || 2000); + const timeout = timeoutMs ?? Number(process.env.EXPRESS_STATUS_POLL_TIMEOUT_MS || 60000); + const deadline = Date.now() + timeout; + + for (;;) { + const result = await getJobStatus(statusUrl); + if (result.status === 'succeeded') return result; + if (result.status === 'failed') throw new Error(`Express job at ${statusUrl} failed`); + if (Date.now() >= deadline) throw new Error(`Express job at ${statusUrl} timed out after ${timeout}ms`); + await sleep(interval); + } +} + +function collectTaggedElements(taggedDocument) { + const elements = []; + for (const page of taggedDocument.documentPages || []) { + for (const element of page.taggedElements || []) { + elements.push({ ...element, pageNumber: page.pageNumber }); + } + } + return elements; +} + +function formatAllowedEdits(name, elements) { + const lines = elements.map((element) => + element.type === 'text' + ? `- ${element.name}: currently "${element.value}"` + : `- ${element.name} (${element.type})` + ); + const example = elements[0]?.name || 'a field'; + return `Edits allowed on "${name}":\n${lines.join('\n')}\nTell me what you'd like to change and to what, e.g. "change ${example} to ...".`; +} + +function pagesForEdits(elements, editKeys) { + const pageNumbers = new Set( + elements.filter((element) => editKeys.includes(element.name)).map((element) => element.pageNumber) + ); + return [...pageNumbers].sort((a, b) => a - b).join(','); +} + +function buildPreferredDocumentName(baseName) { + return `${baseName}-edit-${Date.now()}`; +} + +module.exports = { + getTaggedDocument, + generateVariation, + getJobStatus, + pollJobStatus, + collectTaggedElements, + formatAllowedEdits, + pagesForEdits, + buildPreferredDocumentName, +}; diff --git a/src/express/expressApi.test.js b/src/express/expressApi.test.js new file mode 100644 index 0000000000..c0b974d0c7 --- /dev/null +++ b/src/express/expressApi.test.js @@ -0,0 +1,204 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + getTaggedDocument, + generateVariation, + getJobStatus, + pollJobStatus, + collectTaggedElements, + formatAllowedEdits, + pagesForEdits, + buildPreferredDocumentName, +} = require('./expressApi'); + +const originalFetch = global.fetch; + +function stubFetch(handlers) { + global.fetch = async (url, options) => { + if (url.includes('ims-na1.adobelogin.com')) { + return { ok: true, json: async () => ({ access_token: 'tok-test', expires_in: 86400000 }) }; + } + for (const [pattern, handler] of handlers) { + if (pattern.test(url)) return handler(url, options); + } + throw new Error(`Unexpected fetch call: ${url}`); + }; +} + +test('getTaggedDocument fetches and returns the tagged document', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + stubFetch([ + [/\/beta\/tagged-documents\//, async (url, options) => { + assert.match(url, /\/beta\/tagged-documents\/urn%3Aaaid%3Asc%3AAP%3Aabc$/); + assert.equal(options.headers.Authorization, 'Bearer tok-test'); + assert.equal(options.headers['X-API-KEY'], 'client-1'); + return { ok: true, json: async () => ({ name: 'Croma2-Doc', id: 'urn:aaid:sc:AP:abc', documentPages: [] }) }; + }], + ]); + + const doc = await getTaggedDocument('urn:aaid:sc:AP:abc'); + + assert.equal(doc.name, 'Croma2-Doc'); + global.fetch = originalFetch; +}); + +test('getTaggedDocument throws with the status and body on a non-ok response', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + stubFetch([ + [/\/beta\/tagged-documents\//, async () => ({ ok: false, status: 404, text: async () => 'not found' })], + ]); + + await assert.rejects(() => getTaggedDocument('urn:missing'), /404/); + global.fetch = originalFetch; +}); + +test('generateVariation posts the right body and returns jobId/statusUrl', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + stubFetch([ + [/\/beta\/generate-variation$/, async (url, options) => { + assert.equal(options.method, 'POST'); + const body = JSON.parse(options.body); + assert.deepEqual(body, { + id: 'urn:doc:1', + variationDetails: { + pages: '1', + preferredDocumentName: 'Croma Earbuds-edit-123', + tagMappings: { cta: '20% off' }, + }, + }); + return { ok: true, json: async () => ({ jobId: 'job-1', statusUrl: 'https://express-api.adobe.io/status/job-1' }) }; + }], + ]); + + const result = await generateVariation('urn:doc:1', { cta: '20% off' }, '1', 'Croma Earbuds-edit-123'); + + assert.deepEqual(result, { jobId: 'job-1', statusUrl: 'https://express-api.adobe.io/status/job-1' }); + global.fetch = originalFetch; +}); + +test('getJobStatus fetches the exact statusUrl provided, with no reconstruction', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + const statusUrl = 'https://express-api.adobe.io/status/job-1'; + stubFetch([ + [/\/status\/job-1$/, async (url) => { + assert.equal(url, statusUrl); + return { + ok: true, + json: async () => ({ jobId: 'job-1', status: 'succeeded', document: { name: 'GD2.express', id: 'urn:doc:2', thumbnailUrl: 'https://example.com/thumb.png' } }), + }; + }], + ]); + + const result = await getJobStatus(statusUrl); + + assert.equal(result.status, 'succeeded'); + assert.equal(result.document.thumbnailUrl, 'https://example.com/thumb.png'); + global.fetch = originalFetch; +}); + +test('pollJobStatus resolves once status is succeeded', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + const statusUrl = 'https://express-api.adobe.io/status/job-2'; + let calls = 0; + stubFetch([ + [/\/status\/job-2$/, async (url) => { + assert.equal(url, statusUrl); + calls += 1; + const status = calls < 2 ? 'running' : 'succeeded'; + return { + ok: true, + json: async () => ({ + jobId: 'job-2', + status, + document: status === 'succeeded' ? { thumbnailUrl: 'https://example.com/thumb2.png' } : undefined, + }), + }; + }], + ]); + + const result = await pollJobStatus(statusUrl, { intervalMs: 1, timeoutMs: 1000 }); + + assert.equal(result.status, 'succeeded'); + assert.equal(calls, 2); + global.fetch = originalFetch; +}); + +test('pollJobStatus throws when status is failed', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + const statusUrl = 'https://express-api.adobe.io/status/job-3'; + stubFetch([ + [/\/status\/job-3$/, async () => ({ ok: true, json: async () => ({ jobId: 'job-3', status: 'failed' }) })], + ]); + + await assert.rejects( + () => pollJobStatus(statusUrl, { intervalMs: 1, timeoutMs: 1000 }), + (err) => err.message === `Express job at ${statusUrl} failed` + ); + global.fetch = originalFetch; +}); + +test('pollJobStatus throws once the timeout elapses without succeeding', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + const statusUrl = 'https://express-api.adobe.io/status/job-4'; + stubFetch([ + [/\/status\/job-4$/, async () => ({ ok: true, json: async () => ({ jobId: 'job-4', status: 'running' }) })], + ]); + + await assert.rejects( + () => pollJobStatus(statusUrl, { intervalMs: 5, timeoutMs: 20 }), + (err) => /timed out/.test(err.message) && err.message.includes(statusUrl) + ); + global.fetch = originalFetch; +}); + +test('collectTaggedElements flattens taggedElements across all pages with pageNumber attached', () => { + const doc = { + documentPages: [ + { pageNumber: 1, taggedElements: [{ name: 'heading', type: 'text', value: 'Hi' }] }, + { pageNumber: 2, taggedElements: [{ name: 'footer', type: 'text', value: 'Bye' }] }, + ], + }; + + const elements = collectTaggedElements(doc); + + assert.deepEqual(elements, [ + { name: 'heading', type: 'text', value: 'Hi', pageNumber: 1 }, + { name: 'footer', type: 'text', value: 'Bye', pageNumber: 2 }, + ]); +}); + +test('formatAllowedEdits lists text elements with their current value and non-text elements with just their type', () => { + const elements = [ + { name: 'heading', type: 'text', value: 'Hi', pageNumber: 1 }, + { name: 'logo', type: 'image', pageNumber: 1 }, + ]; + + const message = formatAllowedEdits('Croma Earbuds', elements); + + assert.match(message, /Edits allowed on "Croma Earbuds":/); + assert.match(message, /- heading: currently "Hi"/); + assert.match(message, /- logo \(image\)/); +}); + +test('pagesForEdits returns the sorted, comma-joined page numbers containing the edited fields', () => { + const elements = [ + { name: 'heading', pageNumber: 2 }, + { name: 'cta', pageNumber: 1 }, + { name: 'footer', pageNumber: 1 }, + ]; + + assert.equal(pagesForEdits(elements, ['cta']), '1'); + assert.equal(pagesForEdits(elements, ['cta', 'heading']), '1,2'); +}); + +test('buildPreferredDocumentName appends a timestamp suffix to the base name', () => { + const name = buildPreferredDocumentName('Croma Earbuds'); + assert.match(name, /^Croma Earbuds-edit-\d+$/); +}); diff --git a/src/express/expressAuth.js b/src/express/expressAuth.js new file mode 100644 index 0000000000..a5dfb262c6 --- /dev/null +++ b/src/express/expressAuth.js @@ -0,0 +1,44 @@ +const IMS_TOKEN_URL = process.env.EXPRESS_IMS_TOKEN_URL || 'https://ims-na1.adobelogin.com/ims/token/v3'; +const DEFAULT_SCOPE = 'ee.express_api,openid,AdobeID,read_organizations,additional_info.projectedProductContext'; +const REFRESH_MARGIN_MS = 60_000; + +let cachedToken = null; // { accessToken, expiresAt } + +async function getAccessToken() { + if (cachedToken && cachedToken.expiresAt - Date.now() > REFRESH_MARGIN_MS) { + return cachedToken.accessToken; + } + + const response = await fetch(IMS_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: process.env.EXPRESS_CLIENT_ID, + client_secret: process.env.EXPRESS_CLIENT_SECRET, + scope: process.env.EXPRESS_API_SCOPE || DEFAULT_SCOPE, + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`IMS token request failed ${response.status}: ${text}`); + } + + const data = await response.json(); + cachedToken = { + accessToken: data.access_token, + expiresAt: Date.now() + Number(data.expires_in), + }; + return cachedToken.accessToken; +} + +async function buildAuthHeaders() { + const token = await getAccessToken(); + return { + Authorization: `Bearer ${token}`, + 'X-API-KEY': process.env.EXPRESS_CLIENT_ID, + }; +} + +module.exports = { getAccessToken, buildAuthHeaders }; diff --git a/src/express/expressAuth.test.js b/src/express/expressAuth.test.js new file mode 100644 index 0000000000..b9b7012386 --- /dev/null +++ b/src/express/expressAuth.test.js @@ -0,0 +1,90 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const originalFetch = global.fetch; + +function freshExpressAuth() { + delete require.cache[require.resolve('./expressAuth')]; + return require('./expressAuth'); +} + +test('getAccessToken fetches a token from the IMS endpoint using client credentials', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-123'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-456'; + global.fetch = async (url, options) => { + assert.equal(url, 'https://ims-na1.adobelogin.com/ims/token/v3'); + assert.equal(options.method, 'POST'); + assert.equal(options.headers['Content-Type'], 'application/x-www-form-urlencoded'); + const body = options.body.toString(); + assert.match(body, /grant_type=client_credentials/); + assert.match(body, /client_id=client-123/); + assert.match(body, /client_secret=secret-456/); + return { ok: true, json: async () => ({ access_token: 'tok-abc', expires_in: 86400000, token_type: 'bearer' }) }; + }; + + const { getAccessToken } = freshExpressAuth(); + const token = await getAccessToken(); + + assert.equal(token, 'tok-abc'); + global.fetch = originalFetch; +}); + +test('getAccessToken caches the token and does not refetch on a second call', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-123'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-456'; + let fetchCalls = 0; + global.fetch = async () => { + fetchCalls += 1; + return { ok: true, json: async () => ({ access_token: 'tok-cached', expires_in: 86400000 }) }; + }; + + const { getAccessToken } = freshExpressAuth(); + const first = await getAccessToken(); + const second = await getAccessToken(); + + assert.equal(first, 'tok-cached'); + assert.equal(second, 'tok-cached'); + assert.equal(fetchCalls, 1); + global.fetch = originalFetch; +}); + +test('getAccessToken refetches once the cached token is within 60s of expiring', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-123'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-456'; + let fetchCalls = 0; + global.fetch = async () => { + fetchCalls += 1; + return { ok: true, json: async () => ({ access_token: `tok-${fetchCalls}`, expires_in: 30000 }) }; + }; + + const { getAccessToken } = freshExpressAuth(); + const first = await getAccessToken(); + const second = await getAccessToken(); + + assert.equal(first, 'tok-1'); + assert.equal(second, 'tok-2'); + assert.equal(fetchCalls, 2); + global.fetch = originalFetch; +}); + +test('getAccessToken throws with the status and body when the IMS endpoint errors', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-123'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-456'; + global.fetch = async () => ({ ok: false, status: 401, text: async () => 'invalid client' }); + + const { getAccessToken } = freshExpressAuth(); + await assert.rejects(() => getAccessToken(), /401/); + global.fetch = originalFetch; +}); + +test('buildAuthHeaders returns Authorization and X-API-KEY headers', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-789'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-000'; + global.fetch = async () => ({ ok: true, json: async () => ({ access_token: 'tok-xyz', expires_in: 86400000 }) }); + + const { buildAuthHeaders } = freshExpressAuth(); + const headers = await buildAuthHeaders(); + + assert.deepEqual(headers, { Authorization: 'Bearer tok-xyz', 'X-API-KEY': 'client-789' }); + global.fetch = originalFetch; +}); diff --git a/src/imageStore.js b/src/imageStore.js new file mode 100644 index 0000000000..a2d75c0d02 --- /dev/null +++ b/src/imageStore.js @@ -0,0 +1,43 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +function catalogPath() { + return process.env.EXPRESS_TEMPLATES_FILE || path.join(__dirname, '..', 'data', 'express-templates.json'); +} + +function loadCatalog() { + try { + const raw = fs.readFileSync(catalogPath(), 'utf8'); + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + console.error('[imageStore] failed to read catalog', { path: catalogPath(), message: 'catalog is not an array' }); + return []; + } + return parsed; + } catch (err) { + console.error('[imageStore] failed to read catalog', { path: catalogPath(), message: err.message }); + return []; + } +} + +const conversationEdits = new Map(); + +function getTrackedImages(phoneNumber) { + return loadCatalog().map((entry) => ({ + ...entry, + currentEdits: conversationEdits.get(`${phoneNumber}:${entry.id}`) || {}, + })); +} + +function findTrackedImage(phoneNumber, imageId) { + return getTrackedImages(phoneNumber).find((image) => image.id === imageId); +} + +function recordEdits(phoneNumber, imageId, newEdits) { + const key = `${phoneNumber}:${imageId}`; + const merged = { ...(conversationEdits.get(key) || {}), ...newEdits }; + conversationEdits.set(key, merged); + return merged; +} + +module.exports = { getTrackedImages, findTrackedImage, recordEdits }; diff --git a/src/imageStore.test.js b/src/imageStore.test.js new file mode 100644 index 0000000000..f175afb835 --- /dev/null +++ b/src/imageStore.test.js @@ -0,0 +1,75 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { getTrackedImages, findTrackedImage, recordEdits } = require('./imageStore'); + +function writeFixtureCatalog(entries) { + const fixturePath = path.join(os.tmpdir(), `express-templates-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + fs.writeFileSync(fixturePath, JSON.stringify(entries)); + process.env.EXPRESS_TEMPLATES_FILE = fixturePath; +} + +test('getTrackedImages reads the catalog from EXPRESS_TEMPLATES_FILE', () => { + writeFixtureCatalog([ + { id: 'img_1', name: 'Diwali Offer Banner', docId: 'urn:doc:1' }, + { id: 'img_2', name: 'Summer Sale Flyer', docId: 'urn:doc:2' }, + ]); + + const images = getTrackedImages('phone-1'); + + assert.equal(images.length, 2); + assert.deepEqual(images[0], { id: 'img_1', name: 'Diwali Offer Banner', docId: 'urn:doc:1', currentEdits: {} }); +}); + +test('getTrackedImages returns an empty list when the catalog file is missing', () => { + process.env.EXPRESS_TEMPLATES_FILE = path.join(os.tmpdir(), 'does-not-exist.json'); + + const images = getTrackedImages('phone-2'); + + assert.deepEqual(images, []); +}); + +test('getTrackedImages returns an empty list when the catalog is not an array', () => { + writeFixtureCatalog({}); + + const images = getTrackedImages('phone-2-non-array'); + + assert.deepEqual(images, []); +}); + +test('findTrackedImage returns the matching image by id', () => { + writeFixtureCatalog([{ id: 'img_3', name: 'Croma Earbuds', docId: 'urn:doc:3' }]); + + const image = findTrackedImage('phone-3', 'img_3'); + + assert.equal(image.name, 'Croma Earbuds'); +}); + +test('findTrackedImage returns undefined for an unknown id', () => { + writeFixtureCatalog([{ id: 'img_3', name: 'Croma Earbuds', docId: 'urn:doc:3' }]); + + const image = findTrackedImage('phone-4', 'img_nope'); + + assert.equal(image, undefined); +}); + +test('recordEdits merges edits per phone number and image id, visible via findTrackedImage', () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Diwali Offer Banner', docId: 'urn:doc:1' }]); + + recordEdits('phone-5', 'img_1', { headline: 'Flash Sale' }); + recordEdits('phone-5', 'img_1', { discount_text: '70%' }); + + const image = findTrackedImage('phone-5', 'img_1'); + assert.deepEqual(image.currentEdits, { headline: 'Flash Sale', discount_text: '70%' }); +}); + +test('recordEdits keeps edits independent per phone number', () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Diwali Offer Banner', docId: 'urn:doc:1' }]); + + recordEdits('phone-6', 'img_1', { headline: 'Only for phone-6' }); + + const otherPhoneImage = findTrackedImage('phone-7', 'img_1'); + assert.deepEqual(otherPhoneImage.currentEdits, {}); +}); diff --git a/metaUpload.js b/src/metaUpload.js similarity index 100% rename from metaUpload.js rename to src/metaUpload.js diff --git a/metaUpload.test.js b/src/metaUpload.test.js similarity index 100% rename from metaUpload.test.js rename to src/metaUpload.test.js From 4e7f47d546bd7084a26581f47a2b7101d20e82d9 Mon Sep 17 00:00:00 2001 From: Priyank Modi Date: Tue, 21 Jul 2026 09:50:04 +0530 Subject: [PATCH 10/35] Cap discounts at 40%, add Hindi translation guidance, update graphic catalog - Reject edit_graphic requests with a discount field over 40%, telling the user the real cap instead of silently clamping the value. - Instruct GPT to translate tag text into Devanagari script (not transliteration) when the user asks for a Hindi translation. - Rename catalog entry to "Croma Diwali offer" with its correct docId. - Ignore .env locally. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 1 + data/express-templates.json | 2 +- src/actions.js | 21 +++++++++++++++++++++ src/app.js | 3 ++- 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 84888f4ca4..154f92d291 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules .DS_Store .superpowers +.env diff --git a/data/express-templates.json b/data/express-templates.json index 79fb4b86fc..909564c8a0 100644 --- a/data/express-templates.json +++ b/data/express-templates.json @@ -1,3 +1,3 @@ [ - { "id": "img_1", "name": "Croma Earbuds", "docId": "urn:aaid:sc:AP:aaed427c-b4e4-55e4-b924-74d375f91684" } + { "id": "img_1", "name": "Croma Diwali offer", "docId": "urn:aaid:sc:AP:4b4b25bb-0949-588e-bd7b-76d0e9a113bd" } ] diff --git a/src/actions.js b/src/actions.js index 9c6e29fa46..18ebcd6ebc 100644 --- a/src/actions.js +++ b/src/actions.js @@ -18,6 +18,17 @@ function withCurrentEdits(elements, currentEdits) { ); } +const MAX_DISCOUNT_PERCENT = 40; + +function isDiscountField(name) { + return /discount/i.test(name); +} + +function parsePercent(value) { + const match = String(value).match(/(\d+(?:\.\d+)?)/); + return match ? Number(match[1]) : null; +} + async function actionCheckAllowedEdits(phoneNumber, imageId) { const image = findTrackedImage(phoneNumber, imageId); if (!image) { @@ -59,6 +70,16 @@ async function actionEditGraphic(phoneNumber, imageId, edits, { sendImage }) { return `I can't edit ${disallowedKeys.join(', ')} on "${image.name}". ${expressApi.formatAllowedEdits(image.name, elementsWithCurrentEdits)}`; } + const oversizedDiscountKeys = requestedKeys.filter((key) => { + if (!isDiscountField(key)) return false; + const percent = parsePercent(edits[key]); + return percent !== null && percent > MAX_DISCOUNT_PERCENT; + }); + + if (oversizedDiscountKeys.length > 0) { + return `The maximum discount I can apply on "${image.name}" is ${MAX_DISCOUNT_PERCENT}%. Try again with ${MAX_DISCOUNT_PERCENT}% or less.`; + } + const mergedEdits = { ...image.currentEdits, ...edits }; const pages = expressApi.pagesForEdits(elements, Object.keys(mergedEdits)); const preferredDocumentName = expressApi.buildPreferredDocumentName(image.name); diff --git a/src/app.js b/src/app.js index 1baa2ef9d1..cce25232eb 100644 --- a/src/app.js +++ b/src/app.js @@ -106,7 +106,7 @@ const tools = [ function: { name: 'edit_graphic', description: - 'Edit a specific graphic via Adobe Express API (e.g. change discount text, colors). Pick image_id from the "Images previously sent to this user" list in the system prompt that best matches what the user is referring to.', + 'Edit a specific graphic via Adobe Express API (e.g. change discount text, colors). Pick image_id from the "Images previously sent to this user" list in the system prompt that best matches what the user is referring to. If the user asks to translate a tag\'s text into another language, translate it yourself and pass the translated string as the edit value — for Hindi, always use Devanagari script (e.g. "उपलब्ध"), never a romanized transliteration.', parameters: { type: 'object', properties: { @@ -149,6 +149,7 @@ async function decideAction(phoneNumber, userMessage) { Analyze the user's message and conversation history, then call the appropriate tool. Always call exactly one tool — never reply with plain text. If the request is ambiguous or missing details, use ask_for_more_information. +If the user asks to translate a tag's text into another language (e.g. "change the headline to Hindi"), translate the current text yourself before calling edit_graphic and pass the translated text as the edit value. For Hindi, the translation must be in Devanagari script (e.g. "उपलब्ध"), not a romanized/transliterated form. Images previously sent to this user (reference by id): ${imagesList}`, From 3909999015ca64ae6bfc470422ede47c8b498da5 Mon Sep 17 00:00:00 2001 From: priyankmodiPM <32540484+priyankmodiPM@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:08:07 +0530 Subject: [PATCH 11/35] Turn allowed-edits reply into tappable quick-reply buttons (#6) Sends "Change heading / price / discount / product" as WhatsApp interactive buttons (or a list message when there are more than 3 options) instead of a plain text message, and handles the incoming button/list tap the same way as free-text input. Co-authored-by: Priyank Modi Co-authored-by: Claude Sonnet 5 --- src/actions.js | 7 ++++- src/app.js | 64 ++++++++++++++++++++++++++++++++++++--- src/express/expressApi.js | 22 ++++++++++++-- 3 files changed, 85 insertions(+), 8 deletions(-) diff --git a/src/actions.js b/src/actions.js index 18ebcd6ebc..346f3606ad 100644 --- a/src/actions.js +++ b/src/actions.js @@ -39,7 +39,12 @@ async function actionCheckAllowedEdits(phoneNumber, imageId) { const doc = await expressApi.getTaggedDocument(image.docId); const elements = expressApi.collectTaggedElements(doc); const elementsWithCurrentEdits = withCurrentEdits(elements, image.currentEdits); - return expressApi.formatAllowedEdits(image.name, elementsWithCurrentEdits); + return { + type: 'edit_options', + bodyText: expressApi.formatAllowedEdits(image.name, elementsWithCurrentEdits, { includeInstruction: false }), + options: expressApi.buildEditOptions(elementsWithCurrentEdits), + historyText: expressApi.formatAllowedEdits(image.name, elementsWithCurrentEdits), + }; } catch (err) { console.error('[actionCheckAllowedEdits] Express API error', { docId: image.docId, message: err.message }); return `Sorry, I couldn't check the allowed edits for "${image.name}" right now. Please try again in a moment.`; diff --git a/src/app.js b/src/app.js index cce25232eb..1381172a04 100644 --- a/src/app.js +++ b/src/app.js @@ -61,6 +61,50 @@ function sendImage(to, link) { return whatsappPost({ messaging_product: 'whatsapp', to, type: 'image', image: { link } }); } +// WhatsApp reply-button messages support at most 3 buttons. +function sendButtons(to, bodyText, options) { + return whatsappPost({ + messaging_product: 'whatsapp', + to, + type: 'interactive', + interactive: { + type: 'button', + body: { text: bodyText }, + action: { + buttons: options.map((option) => ({ type: 'reply', reply: { id: option.id, title: option.title } })), + }, + }, + }); +} + +function sendList(to, bodyText, buttonText, options) { + return whatsappPost({ + messaging_product: 'whatsapp', + to, + type: 'interactive', + interactive: { + type: 'list', + body: { text: bodyText }, + action: { + button: buttonText, + sections: [{ title: 'Edits', rows: options.map((option) => ({ id: option.id, title: option.title })) }], + }, + }, + }); +} + +// Sends the list of allowed edits as tappable options rather than plain text, +// falling back to a list message when there are more than fit in reply buttons. +async function sendEditOptions(to, { bodyText, options }) { + if (options.length === 0) { + await sendText(to, bodyText); + } else if (options.length > 3) { + await sendList(to, bodyText, 'Choose a field', options); + } else { + await sendButtons(to, bodyText, options); + } +} + // ── GPT tool definitions ───────────────────────────────────────────────────── const tools = [ @@ -200,7 +244,8 @@ app.post('/', async (req, res) => { if (message.referral) console.log('[webhook] message.referral:', JSON.stringify(message.referral)); if (message.image) console.log('[webhook] message.image:', JSON.stringify(message.image)); - const userText = message?.text?.body; + const interactiveReply = message?.interactive?.button_reply || message?.interactive?.list_reply; + const userText = message?.text?.body || interactiveReply?.title; if (!userText) continue; const phoneNumber = message.from; @@ -217,6 +262,7 @@ app.post('/', async (req, res) => { console.log(`GPT chose action: ${action}`, args); let replyText; + let skipSend = false; switch (action) { case 'list_campaign_graphics': @@ -228,10 +274,18 @@ app.post('/', async (req, res) => { replyText = args.question; break; - case 'check_allowed_edits': + case 'check_allowed_edits': { await sendText(phoneNumber, '⏳ Checking allowed edits...'); - replyText = await actionCheckAllowedEdits(phoneNumber, args.image_id); + const result = await actionCheckAllowedEdits(phoneNumber, args.image_id); + if (typeof result === 'string') { + replyText = result; + } else { + await sendEditOptions(phoneNumber, result); + replyText = result.historyText; + skipSend = true; + } break; + } case 'edit_graphic': await sendText(phoneNumber, '⏳ Applying edits to your graphic...'); @@ -247,7 +301,9 @@ app.post('/', async (req, res) => { replyText = "Sorry, I couldn't figure out how to handle that request."; } - await sendText(phoneNumber, replyText); + if (!skipSend) { + await sendText(phoneNumber, replyText); + } appendHistory(phoneNumber, 'assistant', replyText); } } catch (err) { diff --git a/src/express/expressApi.js b/src/express/expressApi.js index 2be744773e..ed28b817de 100644 --- a/src/express/expressApi.js +++ b/src/express/expressApi.js @@ -69,14 +69,29 @@ function collectTaggedElements(taggedDocument) { return elements; } -function formatAllowedEdits(name, elements) { +function formatAllowedEdits(name, elements, { includeInstruction = true } = {}) { const lines = elements.map((element) => element.type === 'text' ? `- ${element.name}: currently "${element.value}"` : `- ${element.name} (${element.type})` ); - const example = elements[0]?.name || 'a field'; - return `Edits allowed on "${name}":\n${lines.join('\n')}\nTell me what you'd like to change and to what, e.g. "change ${example} to ...".`; + let text = `Edits allowed on "${name}":\n${lines.join('\n')}`; + if (includeInstruction) { + const example = elements[0]?.name || 'a field'; + text += `\nTell me what you'd like to change and to what, e.g. "change ${example} to ...".`; + } + return text; +} + +function humanizeFieldName(name) { + return name.replace(/_(text|image)$/i, '').replace(/_/g, ' '); +} + +function buildEditOptions(elements) { + return elements.map((element) => ({ + id: `edit:${element.name}`, + title: `Change ${humanizeFieldName(element.name)}`.slice(0, 20), + })); } function pagesForEdits(elements, editKeys) { @@ -97,6 +112,7 @@ module.exports = { pollJobStatus, collectTaggedElements, formatAllowedEdits, + buildEditOptions, pagesForEdits, buildPreferredDocumentName, }; From db1326a08a9642483101bd92a6864d2a6d47d127 Mon Sep 17 00:00:00 2001 From: Priyank Modi Date: Tue, 21 Jul 2026 14:12:20 +0530 Subject: [PATCH 12/35] Always render edit options as native reply buttons Replace the list-picker fallback with additional button messages (3 per message, WhatsApp's per-message cap) so options over 3 still show up as tappable buttons instead of a "Choose a field" dropdown. Co-Authored-By: Claude Sonnet 5 --- src/app.js | 29 ++++++++--------------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/src/app.js b/src/app.js index 1381172a04..b27d607925 100644 --- a/src/app.js +++ b/src/app.js @@ -77,31 +77,18 @@ function sendButtons(to, bodyText, options) { }); } -function sendList(to, bodyText, buttonText, options) { - return whatsappPost({ - messaging_product: 'whatsapp', - to, - type: 'interactive', - interactive: { - type: 'list', - body: { text: bodyText }, - action: { - button: buttonText, - sections: [{ title: 'Edits', rows: options.map((option) => ({ id: option.id, title: option.title })) }], - }, - }, - }); -} +// WhatsApp reply-button messages cap out at 3 buttons, so options beyond that +// go out as additional button messages rather than falling back to a list picker. +const BUTTONS_PER_MESSAGE = 3; -// Sends the list of allowed edits as tappable options rather than plain text, -// falling back to a list message when there are more than fit in reply buttons. async function sendEditOptions(to, { bodyText, options }) { if (options.length === 0) { await sendText(to, bodyText); - } else if (options.length > 3) { - await sendList(to, bodyText, 'Choose a field', options); - } else { - await sendButtons(to, bodyText, options); + return; + } + for (let i = 0; i < options.length; i += BUTTONS_PER_MESSAGE) { + const chunk = options.slice(i, i + BUTTONS_PER_MESSAGE); + await sendButtons(to, i === 0 ? bodyText : 'More edits:', chunk); } } From 15525ff736ec289da419c20f2f3dae2b5e3c9bf2 Mon Sep 17 00:00:00 2001 From: Priyank Modi Date: Tue, 21 Jul 2026 14:40:52 +0530 Subject: [PATCH 13/35] Simplify edit-options prompt and button labels Show a short "What would you like to change?" prompt instead of dumping every field's current value into the button message body, and humanize camelCase field names (e.g. discountPercentage -> "Change discount") instead of only stripping _text/_image suffixes. The detailed field listing is still kept in conversation history for GPT context. Co-Authored-By: Claude Sonnet 5 --- src/actions.js | 2 +- src/express/expressApi.js | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/actions.js b/src/actions.js index 346f3606ad..de92313c45 100644 --- a/src/actions.js +++ b/src/actions.js @@ -41,7 +41,7 @@ async function actionCheckAllowedEdits(phoneNumber, imageId) { const elementsWithCurrentEdits = withCurrentEdits(elements, image.currentEdits); return { type: 'edit_options', - bodyText: expressApi.formatAllowedEdits(image.name, elementsWithCurrentEdits, { includeInstruction: false }), + bodyText: 'What would you like to change?', options: expressApi.buildEditOptions(elementsWithCurrentEdits), historyText: expressApi.formatAllowedEdits(image.name, elementsWithCurrentEdits), }; diff --git a/src/express/expressApi.js b/src/express/expressApi.js index ed28b817de..cb7ff9a857 100644 --- a/src/express/expressApi.js +++ b/src/express/expressApi.js @@ -84,13 +84,25 @@ function formatAllowedEdits(name, elements, { includeInstruction = true } = {}) } function humanizeFieldName(name) { - return name.replace(/_(text|image)$/i, '').replace(/_/g, ' '); + return name + .replace(/_/g, ' ') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .toLowerCase(); +} + +// WhatsApp reply-button titles are capped at 20 characters; trim on a word boundary +// rather than cutting mid-word. +function truncateTitle(title, maxLength = 20) { + if (title.length <= maxLength) return title; + const truncated = title.slice(0, maxLength); + const lastSpace = truncated.lastIndexOf(' '); + return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated; } function buildEditOptions(elements) { return elements.map((element) => ({ id: `edit:${element.name}`, - title: `Change ${humanizeFieldName(element.name)}`.slice(0, 20), + title: truncateTitle(`Change ${humanizeFieldName(element.name)}`), })); } From 540a43ee05a8fe22809c14dd08c0245512d1d084 Mon Sep 17 00:00:00 2001 From: priyankmodiPM <32540484+priyankmodiPM@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:48:18 +0530 Subject: [PATCH 14/35] Feat/edit options quick reply buttons (#7) * Always render edit options as native reply buttons Replace the list-picker fallback with additional button messages (3 per message, WhatsApp's per-message cap) so options over 3 still show up as tappable buttons instead of a "Choose a field" dropdown. Co-Authored-By: Claude Sonnet 5 * Simplify edit-options prompt and button labels Show a short "What would you like to change?" prompt instead of dumping every field's current value into the button message body, and humanize camelCase field names (e.g. discountPercentage -> "Change discount") instead of only stripping _text/_image suffixes. The detailed field listing is still kept in conversation history for GPT context. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Priyank Modi Co-authored-by: Claude Sonnet 5 --- src/actions.js | 2 +- src/app.js | 29 ++++++++--------------------- src/express/expressApi.js | 16 ++++++++++++++-- 3 files changed, 23 insertions(+), 24 deletions(-) diff --git a/src/actions.js b/src/actions.js index 346f3606ad..de92313c45 100644 --- a/src/actions.js +++ b/src/actions.js @@ -41,7 +41,7 @@ async function actionCheckAllowedEdits(phoneNumber, imageId) { const elementsWithCurrentEdits = withCurrentEdits(elements, image.currentEdits); return { type: 'edit_options', - bodyText: expressApi.formatAllowedEdits(image.name, elementsWithCurrentEdits, { includeInstruction: false }), + bodyText: 'What would you like to change?', options: expressApi.buildEditOptions(elementsWithCurrentEdits), historyText: expressApi.formatAllowedEdits(image.name, elementsWithCurrentEdits), }; diff --git a/src/app.js b/src/app.js index 1381172a04..b27d607925 100644 --- a/src/app.js +++ b/src/app.js @@ -77,31 +77,18 @@ function sendButtons(to, bodyText, options) { }); } -function sendList(to, bodyText, buttonText, options) { - return whatsappPost({ - messaging_product: 'whatsapp', - to, - type: 'interactive', - interactive: { - type: 'list', - body: { text: bodyText }, - action: { - button: buttonText, - sections: [{ title: 'Edits', rows: options.map((option) => ({ id: option.id, title: option.title })) }], - }, - }, - }); -} +// WhatsApp reply-button messages cap out at 3 buttons, so options beyond that +// go out as additional button messages rather than falling back to a list picker. +const BUTTONS_PER_MESSAGE = 3; -// Sends the list of allowed edits as tappable options rather than plain text, -// falling back to a list message when there are more than fit in reply buttons. async function sendEditOptions(to, { bodyText, options }) { if (options.length === 0) { await sendText(to, bodyText); - } else if (options.length > 3) { - await sendList(to, bodyText, 'Choose a field', options); - } else { - await sendButtons(to, bodyText, options); + return; + } + for (let i = 0; i < options.length; i += BUTTONS_PER_MESSAGE) { + const chunk = options.slice(i, i + BUTTONS_PER_MESSAGE); + await sendButtons(to, i === 0 ? bodyText : 'More edits:', chunk); } } diff --git a/src/express/expressApi.js b/src/express/expressApi.js index ed28b817de..cb7ff9a857 100644 --- a/src/express/expressApi.js +++ b/src/express/expressApi.js @@ -84,13 +84,25 @@ function formatAllowedEdits(name, elements, { includeInstruction = true } = {}) } function humanizeFieldName(name) { - return name.replace(/_(text|image)$/i, '').replace(/_/g, ' '); + return name + .replace(/_/g, ' ') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .toLowerCase(); +} + +// WhatsApp reply-button titles are capped at 20 characters; trim on a word boundary +// rather than cutting mid-word. +function truncateTitle(title, maxLength = 20) { + if (title.length <= maxLength) return title; + const truncated = title.slice(0, maxLength); + const lastSpace = truncated.lastIndexOf(' '); + return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated; } function buildEditOptions(elements) { return elements.map((element) => ({ id: `edit:${element.name}`, - title: `Change ${humanizeFieldName(element.name)}`.slice(0, 20), + title: truncateTitle(`Change ${humanizeFieldName(element.name)}`), })); } From 13d3bffec2ca1f8ee7a8fd9cd8e580f27da99998 Mon Sep 17 00:00:00 2001 From: varun kalra Date: Tue, 21 Jul 2026 23:05:16 +0530 Subject: [PATCH 15/35] feat: add create_design flow for brand-new creatives (Onam) (#8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Flow 2.2 — creating a brand-new creative from a text description, assembled from approved brand elements, with canned images. - add create_design tool + action; registers a per-phone, in-memory source: 'local' design backed by canned image URLs (data/onam-design.json) - route edit_graphic / check_allowed_edits on image source: local designs skip the Adobe Express API; catalog designs keep the existing Express path - palette guardrail: reject off-palette background edits and offer the approved festive accents (Marigold / Maroon / Deep Green) - Malayalam banner translation via canned image (detect Malayalam script) - tighten tool descriptions + system prompt so messages that carry concrete values route to edit_graphic instead of check_allowed_edits - add one log line per action for observability - tests: cover create/edit/guardrail/translation; update stale expectations Co-authored-by: varun kalra Co-authored-by: Claude Opus 4.8 (1M context) --- data/onam-design.json | 21 ++++++ src/actions.js | 142 +++++++++++++++++++++++++++++++++++++-- src/actions.test.js | 12 ++-- src/app.js | 37 +++++++++- src/createDesign.test.js | 104 ++++++++++++++++++++++++++++ src/imageStore.js | 20 +++++- src/imageStore.test.js | 2 +- 7 files changed, 321 insertions(+), 17 deletions(-) create mode 100644 data/onam-design.json create mode 100644 src/createDesign.test.js diff --git a/data/onam-design.json b/data/onam-design.json new file mode 100644 index 0000000000..5cc39839c9 --- /dev/null +++ b/data/onam-design.json @@ -0,0 +1,21 @@ +{ + "_comment": "Template used by the create_design tool for brand-new (source: 'local') creatives. Swap the placeholder image URLs below with your hosted PNG URLs. See README / actions.js for how they map to edit state.", + "images": { + "base": "http://s7ap1.scene7.com/is/image/varunAEM/onam-base", + "final": "http://s7ap1.scene7.com/is/image/varunAEM/onam-final", + "malayalam": "http://s7ap1.scene7.com/is/image/varunAEM/onam-malyalam" + }, + "palette": [ + { "name": "Marigold", "hex": "#F4A300" }, + { "name": "Maroon", "hex": "#800020" }, + { "name": "Deep Green", "hex": "#1B5E20" } + ], + "slots": { + "editable": [ + { "name": "headline", "type": "text", "aliases": ["heading", "title", "header", "banner_text", "text"] }, + { "name": "background", "type": "color", "aliases": ["background_color", "bg", "colour", "color", "background_colour"] }, + { "name": "address", "type": "text", "aliases": ["store_address", "location", "store"] } + ], + "locked": ["logo", "product", "background_image"] + } +} diff --git a/src/actions.js b/src/actions.js index de92313c45..4fdbfb0db5 100644 --- a/src/actions.js +++ b/src/actions.js @@ -1,6 +1,13 @@ -const { getTrackedImages, findTrackedImage, recordEdits } = require('./imageStore'); +const fs = require('node:fs'); +const path = require('node:path'); +const { getTrackedImages, findTrackedImage, recordEdits, createDesign } = require('./imageStore'); const expressApi = require('./express/expressApi'); +function loadOnamDesign() { + const filePath = process.env.ONAM_DESIGN_FILE || path.join(__dirname, '..', 'data', 'onam-design.json'); + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + function formatUnknownImageMessage(phoneNumber) { const images = getTrackedImages(phoneNumber); const list = images.map((image) => `- ${image.name}`).join('\n'); @@ -8,10 +15,31 @@ function formatUnknownImageMessage(phoneNumber) { } async function actionListCampaignGraphics() { + console.log('[action:list_campaign_graphics]'); // TODO: fetch from campaign API return 'Graphics in your current campaign:\n1. Croma Earbuds'; } +// Create a brand-new creative from a text description (Flow 2.2 — Onam). +// Unlike the catalog designs, this has no Adobe Express document; it is a +// `source: 'local'` design that resolves to canned images (see data/onam-design.json). +async function actionCreateDesign(phoneNumber, { occasion, products, offer } = {}, { sendImage }) { + const design = loadOnamDesign(); + const productList = Array.isArray(products) && products.length ? products.join(' + ') : (products || 'your products'); + const name = [occasion || 'Festive', productList, 'offer'].filter(Boolean).join(' '); + console.log('[action:create_design]', { phoneNumber, occasion, products, offer, image: design.images.base }); + createDesign(phoneNumber, { name, design }); + + try { + await sendImage(phoneNumber, design.images.base); + } catch (err) { + console.error('[actionCreateDesign] sendImage error', { message: err.message }); + return `I built your ${occasion || 'festive'} design, but couldn't send the image right now — try asking me to resend it.`; + } + + return `Here you go 🌼 Built with Croma's logo, approved festive colours and the ${productList} images. Want to tweak anything?`; +} + function withCurrentEdits(elements, currentEdits) { return elements.map((element) => element.name in currentEdits ? { ...element, value: currentEdits[element.name] } : element @@ -29,12 +57,102 @@ function parsePercent(value) { return match ? Number(match[1]) : null; } +// ── Local (canned-image) design helpers — Flow 2.2 ─────────────────────────── + +function normalizeKey(key) { + return String(key).toLowerCase().trim().replace(/\s+/g, '_'); +} + +// Map GPT's free-form edit keys onto the design's canonical slot names via +// aliases, so "background_color" / "colour" / "heading" all resolve correctly. +function canonicalizeEdits(editableSlots, edits) { + const canonical = {}; + const unknown = []; + for (const [rawKey, value] of Object.entries(edits || {})) { + const key = normalizeKey(rawKey); + const slot = editableSlots.find( + (s) => normalizeKey(s.name) === key || (s.aliases || []).some((a) => normalizeKey(a) === key) + ); + if (slot) canonical[slot.name] = value; + else unknown.push(rawKey); + } + return { canonical, unknown }; +} + +function isPaletteColor(design, value) { + const v = String(value).trim().toLowerCase(); + return (design.palette || []).some((c) => c.name.toLowerCase() === v || c.hex.toLowerCase() === v); +} + +function hasMalayalam(value) { + return /[ഀ-ൿ]/.test(String(value)); +} + +// Pick the canned image for the current accumulated edit state. +function resolveLocalImage(design, currentEdits) { + const values = Object.values(currentEdits); + if (values.some(hasMalayalam)) return design.images.malayalam; + if (currentEdits.background || currentEdits.address) return design.images.final; + return design.images.base; +} + +function localEditElements(image) { + return image.design.slots.editable.map((slot) => ({ + name: slot.name, + type: slot.type || 'text', + value: image.currentEdits[slot.name] ?? '', + })); +} + +async function editLocalDesign(phoneNumber, image, rawEdits, { sendImage }) { + const design = image.design; + const editableSlots = design.slots.editable; + const { canonical: edits, unknown } = canonicalizeEdits(editableSlots, rawEdits); + + if (unknown.length > 0) { + const editableNames = editableSlots.map((s) => s.name).join(', '); + return `I can't edit ${unknown.join(', ')} on "${image.name}" — those are locked by HQ. You can change: ${editableNames}.`; + } + + if ('background' in edits && !isPaletteColor(design, edits.background)) { + const options = design.palette.map((c) => c.name).join(' · '); + return `"${edits.background}" isn't in the approved palette 🙂 Here are the festive accents you can pick from: ${options}`; + } + + recordEdits(phoneNumber, image.id, edits); + const currentEdits = { ...image.currentEdits, ...edits }; + const imageUrl = resolveLocalImage(design, currentEdits); + console.log('[edit:local] resolved image', { imageId: image.id, currentEdits, imageUrl }); + + const summary = Object.entries(edits).map(([key, value]) => `• ${key}: ${value}`).join('\n'); + + try { + await sendImage(phoneNumber, imageUrl); + } catch (err) { + console.error('[editLocalDesign] sendImage error', { imageId: image.id, message: err.message }); + return `Updated "${image.name}", but I couldn't send the image right now — try asking me to resend it.`; + } + + return `Updated "${image.name}":\n${summary}`; +} + async function actionCheckAllowedEdits(phoneNumber, imageId) { const image = findTrackedImage(phoneNumber, imageId); + console.log('[action:check_allowed_edits]', { phoneNumber, imageId, source: image?.source ?? 'not_found' }); if (!image) { return formatUnknownImageMessage(phoneNumber); } + if (image.source === 'local') { + const elements = localEditElements(image); + return { + type: 'edit_options', + bodyText: 'What would you like to change?', + options: expressApi.buildEditOptions(elements), + historyText: expressApi.formatAllowedEdits(image.name, elements), + }; + } + try { const doc = await expressApi.getTaggedDocument(image.docId); const elements = expressApi.collectTaggedElements(doc); @@ -51,18 +169,29 @@ async function actionCheckAllowedEdits(phoneNumber, imageId) { } } +// Routes to the right edit path based on where the design came from: +// source: 'local' → canned-image design (Flow 2.2), no Express calls +// source: 'express' → real Adobe Express-backed catalog design (Flow 2.1) async function actionEditGraphic(phoneNumber, imageId, edits, { sendImage }) { const image = findTrackedImage(phoneNumber, imageId); + console.log('[action:edit_graphic]', { phoneNumber, imageId, source: image?.source ?? 'not_found', edits }); if (!image) { return formatUnknownImageMessage(phoneNumber); } + if (image.source === 'local') { + return editLocalDesign(phoneNumber, image, edits, { sendImage }); + } + return editExpressDesign(phoneNumber, image, edits, { sendImage }); +} + +async function editExpressDesign(phoneNumber, image, edits, { sendImage }) { let elements; try { const doc = await expressApi.getTaggedDocument(image.docId); elements = expressApi.collectTaggedElements(doc); } catch (err) { - console.error('[actionEditGraphic] Express API error', { docId: image.docId, message: err.message }); + console.error('[editExpressDesign] Express API error', { docId: image.docId, message: err.message }); return `Sorry, I couldn't reach Adobe Express to apply that edit. Please try again in a moment.`; } @@ -94,19 +223,20 @@ async function actionEditGraphic(phoneNumber, imageId, edits, { sendImage }) { const { statusUrl } = await expressApi.generateVariation(image.docId, mergedEdits, pages, preferredDocumentName); const result = await expressApi.pollJobStatus(statusUrl); thumbnailUrl = result.document.thumbnailUrl; + console.log('[edit:express] resolved image', { imageId: image.id, docId: image.docId, thumbnailUrl }); } catch (err) { - console.error('[actionEditGraphic] generate/poll error', { docId: image.docId, message: err.message }); + console.error('[editExpressDesign] generate/poll error', { docId: image.docId, message: err.message }); return `Sorry, something went wrong generating your updated "${image.name}". Please try again.`; } - recordEdits(phoneNumber, imageId, edits); + recordEdits(phoneNumber, image.id, edits); const summary = Object.entries(edits).map(([key, value]) => `• ${key}: ${value}`).join('\n'); try { await sendImage(phoneNumber, thumbnailUrl); } catch (err) { - console.error('[actionEditGraphic] sendImage error', { docId: image.docId, message: err.message }); + console.error('[editExpressDesign] sendImage error', { docId: image.docId, message: err.message }); return `Updated "${image.name}", but I couldn't send the image right now — try asking me to resend it.`; } @@ -114,12 +244,14 @@ async function actionEditGraphic(phoneNumber, imageId, edits, { sendImage }) { } async function actionGenerateBulkGraphics(filename) { + console.log('[action:generate_bulk_graphics]', { filename }); // TODO: parse CSV/Excel and call Adobe Express API per row return `Bulk generation complete! Graphics created from ${filename || 'your uploaded file'}.`; } module.exports = { actionListCampaignGraphics, + actionCreateDesign, actionCheckAllowedEdits, actionEditGraphic, actionGenerateBulkGraphics, diff --git a/src/actions.test.js b/src/actions.test.js index a662e30598..1b16b26ced 100644 --- a/src/actions.test.js +++ b/src/actions.test.js @@ -34,9 +34,9 @@ test('actionCheckAllowedEdits lists the tagged elements for a known image', asyn const reply = await actionCheckAllowedEdits('phone-1', 'img_1'); - assert.match(reply, /Croma Earbuds/); - assert.match(reply, /heading: currently "The X-Phone Pro is here!"/); - assert.match(reply, /cta: currently/); + assert.match(reply.historyText, /Croma Earbuds/); + assert.match(reply.historyText, /heading: currently "The X-Phone Pro is here!"/); + assert.match(reply.historyText, /cta: currently/); }); test('actionCheckAllowedEdits shows the latest edited value instead of the stale original document value', async () => { @@ -46,9 +46,9 @@ test('actionCheckAllowedEdits shows the latest edited value instead of the stale const reply = await actionCheckAllowedEdits('phone-1b', 'img_1'); - assert.match(reply, /cta: currently "20% off"/); - assert.doesNotMatch(reply, /Available at our store starting 15 Aug 20XX\./); - assert.match(reply, /heading: currently "The X-Phone Pro is here!"/); + assert.match(reply.historyText, /cta: currently "20% off"/); + assert.doesNotMatch(reply.historyText, /Available at our store starting 15 Aug 20XX\./); + assert.match(reply.historyText, /heading: currently "The X-Phone Pro is here!"/); }); test('actionCheckAllowedEdits reports unknown images without throwing', async () => { diff --git a/src/app.js b/src/app.js index b27d607925..fe965dcff9 100644 --- a/src/app.js +++ b/src/app.js @@ -3,6 +3,7 @@ const OpenAI = require('openai'); const { getTrackedImages } = require('./imageStore'); const { actionListCampaignGraphics, + actionCreateDesign, actionCheckAllowedEdits, actionEditGraphic, actionGenerateBulkGraphics, @@ -103,6 +104,27 @@ const tools = [ parameters: { type: 'object', properties: {} }, }, }, + { + type: 'function', + function: { + name: 'create_design', + description: + 'Create a brand-new single marketing creative from a text description, for an occasion or theme that has NO existing template (e.g. Onam, Pongal). Use when the user wants to make/create/design a new banner or creative from scratch. Do NOT use this for bulk generation from a CSV/Excel file — that is generate_bulk_graphics.', + parameters: { + type: 'object', + properties: { + occasion: { type: 'string', description: 'The occasion or theme, e.g. "Onam"' }, + products: { + type: 'array', + items: { type: 'string' }, + description: 'Products to feature, e.g. ["LG washing machine", "dishwasher"]', + }, + offer: { type: 'string', description: 'The offer or discount to show, e.g. "20% off"' }, + }, + required: ['occasion'], + }, + }, + }, { type: 'function', function: { @@ -122,7 +144,7 @@ const tools = [ function: { name: 'check_allowed_edits', description: - 'Check what edits are permitted on a specific graphic. Pick image_id from the "Images previously sent to this user" list in the system prompt that best matches what the user is referring to.', + 'Show the list of fields the user CAN edit on a graphic. Use this ONLY when the user asks what can be changed / wants the options (e.g. "what can I edit?", "edit", "make changes") and does NOT give a specific new value. If the user already states a change and its value, use edit_graphic instead. Pick image_id from the "Images previously sent to this user" list in the system prompt that best matches what the user is referring to.', parameters: { type: 'object', properties: { @@ -137,7 +159,7 @@ const tools = [ function: { name: 'edit_graphic', description: - 'Edit a specific graphic via Adobe Express API (e.g. change discount text, colors). Pick image_id from the "Images previously sent to this user" list in the system prompt that best matches what the user is referring to. If the user asks to translate a tag\'s text into another language, translate it yourself and pass the translated string as the edit value — for Hindi, always use Devanagari script (e.g. "उपलब्ध"), never a romanized transliteration.', + 'Apply one or more concrete edits to a specific graphic. Use this WHENEVER the user states what to change AND the new value — e.g. "make the background marigold", "add my address MG Road Kochi", "change the offer to 7500", "translate the banner to Malayalam". Put every requested change into the edits object (multiple keys allowed). Pick image_id from the "Images previously sent to this user" list in the system prompt that best matches what the user is referring to. If the user asks to translate a tag\'s text into another language, translate it yourself and pass the translated string as the edit value — for Hindi always use Devanagari script (e.g. "उपलब्ध"), for Malayalam always use Malayalam script (e.g. "ഓണം"), never a romanized transliteration.', parameters: { type: 'object', properties: { @@ -180,7 +202,10 @@ async function decideAction(phoneNumber, userMessage) { Analyze the user's message and conversation history, then call the appropriate tool. Always call exactly one tool — never reply with plain text. If the request is ambiguous or missing details, use ask_for_more_information. -If the user asks to translate a tag's text into another language (e.g. "change the headline to Hindi"), translate the current text yourself before calling edit_graphic and pass the translated text as the edit value. For Hindi, the translation must be in Devanagari script (e.g. "उपलब्ध"), not a romanized/transliterated form. +If the user wants a brand-new creative for an occasion that has no existing template (e.g. Onam, Pongal), use create_design. +Choosing between edit_graphic and check_allowed_edits: if the user's message already contains a concrete change and its value (e.g. "make the background marigold", "add my address MG Road Kochi"), call edit_graphic with all of those changes in the edits object. Only call check_allowed_edits when the user asks what can be changed or wants the list of options WITHOUT giving a specific value. +When editing, prefer these field names when they apply: headline, background, address, offer. +If the user asks to translate a tag's text into another language (e.g. "change the headline to Hindi", "translate the banner to Malayalam"), translate the current text yourself before calling edit_graphic and pass the translated text as the edit value. For Hindi, use Devanagari script (e.g. "उपलब्ध"); for Malayalam, use Malayalam script (e.g. "ഓണം"). Never use a romanized/transliterated form. Images previously sent to this user (reference by id): ${imagesList}`, @@ -257,7 +282,13 @@ app.post('/', async (req, res) => { replyText = await actionListCampaignGraphics(); break; + case 'create_design': + await sendText(phoneNumber, '🎨 Creating your design, this may take a moment...'); + replyText = await actionCreateDesign(phoneNumber, args, { sendImage }); + break; + case 'ask_for_more_information': + console.log('[action:ask_for_more_information]', { question: args.question }); replyText = args.question; break; diff --git a/src/createDesign.test.js b/src/createDesign.test.js new file mode 100644 index 0000000000..da5adf0c54 --- /dev/null +++ b/src/createDesign.test.js @@ -0,0 +1,104 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { actionCreateDesign, actionEditGraphic } = require('./actions'); + +const FIXTURE = { + images: { + base: 'https://cdn.test/onam-base.png', + final: 'https://cdn.test/onam-final.png', + malayalam: 'https://cdn.test/onam-malayalam.png', + }, + palette: [ + { name: 'Marigold', hex: '#F4A300' }, + { name: 'Maroon', hex: '#800020' }, + { name: 'Deep Green', hex: '#1B5E20' }, + ], + slots: { + editable: [ + { name: 'headline', type: 'text', aliases: ['heading', 'title'] }, + { name: 'background', type: 'color', aliases: ['background_color', 'colour', 'color'] }, + { name: 'address', type: 'text', aliases: ['store_address'] }, + ], + locked: ['logo', 'product'], + }, +}; + +function useFixture() { + const p = path.join(os.tmpdir(), `onam-design-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + fs.writeFileSync(p, JSON.stringify(FIXTURE)); + process.env.ONAM_DESIGN_FILE = p; +} + +function captureSendImage() { + const sent = []; + return { sendImage: async (_to, link) => sent.push(link), sent }; +} + +test('actionCreateDesign registers a local design and sends the base image', async () => { + useFixture(); + const { sendImage, sent } = captureSendImage(); + + const reply = await actionCreateDesign( + 'onam-phone-1', + { occasion: 'Onam', products: ['LG washing machine', 'dishwasher'], offer: '20% off' }, + { sendImage } + ); + + assert.equal(sent[0], FIXTURE.images.base); + assert.match(reply, /Built with Croma's logo/); +}); + +test('editing a created design to an off-palette background is rejected with approved options', async () => { + useFixture(); + const { sendImage, sent } = captureSendImage(); + await actionCreateDesign('onam-phone-2', { occasion: 'Onam' }, { sendImage }); + + const reply = await actionEditGraphic('onam-phone-2', 'local_1', { background: 'Pink' }, { sendImage }); + + assert.match(reply, /isn't in the approved palette/); + assert.match(reply, /Marigold · Maroon · Deep Green/); + assert.equal(sent.length, 1); // only the create image; no new image on a rejected edit +}); + +test('an approved-palette background + address resolves to the final image', async () => { + useFixture(); + const { sendImage, sent } = captureSendImage(); + await actionCreateDesign('onam-phone-3', { occasion: 'Onam' }, { sendImage }); + + await actionEditGraphic('onam-phone-3', 'local_1', { background: 'Marigold', address: 'MG Road, Kochi' }, { sendImage }); + + assert.equal(sent.at(-1), FIXTURE.images.final); +}); + +test('a Malayalam headline resolves to the Malayalam image', async () => { + useFixture(); + const { sendImage, sent } = captureSendImage(); + await actionCreateDesign('onam-phone-4', { occasion: 'Onam' }, { sendImage }); + + await actionEditGraphic('onam-phone-4', 'local_1', { headline: 'ഓണം ആശംസകൾ' }, { sendImage }); + + assert.equal(sent.at(-1), FIXTURE.images.malayalam); +}); + +test('editing a locked element on a created design is refused', async () => { + useFixture(); + const { sendImage } = captureSendImage(); + await actionCreateDesign('onam-phone-5', { occasion: 'Onam' }, { sendImage }); + + const reply = await actionEditGraphic('onam-phone-5', 'local_1', { logo: 'brighter' }, { sendImage }); + + assert.match(reply, /locked by HQ/); +}); + +test('edit-key aliases map onto canonical slot names (colour -> background)', async () => { + useFixture(); + const { sendImage, sent } = captureSendImage(); + await actionCreateDesign('onam-phone-6', { occasion: 'Onam' }, { sendImage }); + + await actionEditGraphic('onam-phone-6', 'local_1', { colour: 'Deep Green' }, { sendImage }); + + assert.equal(sent.at(-1), FIXTURE.images.final); +}); diff --git a/src/imageStore.js b/src/imageStore.js index a2d75c0d02..c9dd11ce65 100644 --- a/src/imageStore.js +++ b/src/imageStore.js @@ -22,8 +22,24 @@ function loadCatalog() { const conversationEdits = new Map(); +// Brand-new designs created at runtime via create_design, keyed by phone number. +// These are `source: 'local'` (canned images) as opposed to the `source: 'express'` +// catalog entries which are backed by the real Adobe Express API. +const createdDesigns = new Map(); + +function createDesign(phoneNumber, design) { + const list = createdDesigns.get(phoneNumber) || []; + const id = `local_${list.length + 1}`; + const entry = { id, source: 'local', ...design }; + list.push(entry); + createdDesigns.set(phoneNumber, list); + return entry; +} + function getTrackedImages(phoneNumber) { - return loadCatalog().map((entry) => ({ + const catalog = loadCatalog().map((entry) => ({ ...entry, source: 'express' })); + const created = createdDesigns.get(phoneNumber) || []; + return [...catalog, ...created].map((entry) => ({ ...entry, currentEdits: conversationEdits.get(`${phoneNumber}:${entry.id}`) || {}, })); @@ -40,4 +56,4 @@ function recordEdits(phoneNumber, imageId, newEdits) { return merged; } -module.exports = { getTrackedImages, findTrackedImage, recordEdits }; +module.exports = { getTrackedImages, findTrackedImage, recordEdits, createDesign }; diff --git a/src/imageStore.test.js b/src/imageStore.test.js index f175afb835..d15b37d2be 100644 --- a/src/imageStore.test.js +++ b/src/imageStore.test.js @@ -20,7 +20,7 @@ test('getTrackedImages reads the catalog from EXPRESS_TEMPLATES_FILE', () => { const images = getTrackedImages('phone-1'); assert.equal(images.length, 2); - assert.deepEqual(images[0], { id: 'img_1', name: 'Diwali Offer Banner', docId: 'urn:doc:1', currentEdits: {} }); + assert.deepEqual(images[0], { id: 'img_1', name: 'Diwali Offer Banner', docId: 'urn:doc:1', source: 'express', currentEdits: {} }); }); test('getTrackedImages returns an empty list when the catalog file is missing', () => { From 3914c13ee69105fcd276c986edcf4bd5b277fe75 Mon Sep 17 00:00:00 2001 From: Priyank Modi Date: Wed, 22 Jul 2026 11:02:10 +0530 Subject: [PATCH 16/35] Wire quick-reply button taps to the actual field/image being edited Button/list-row ids now encode edit:: instead of just a display title, so tapping an edit option tells GPT precisely which field to edit instead of a truncated, ambiguous label. GPT now asks for the missing value and applies it via edit_graphic once supplied, rather than never reaching the Express API. Co-Authored-By: Claude Sonnet 5 --- src/actions.js | 2 +- src/app.js | 20 +++++++++++++++++++- src/express/expressApi.js | 4 ++-- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/actions.js b/src/actions.js index de92313c45..7742eb2567 100644 --- a/src/actions.js +++ b/src/actions.js @@ -42,7 +42,7 @@ async function actionCheckAllowedEdits(phoneNumber, imageId) { return { type: 'edit_options', bodyText: 'What would you like to change?', - options: expressApi.buildEditOptions(elementsWithCurrentEdits), + options: expressApi.buildEditOptions(elementsWithCurrentEdits, imageId), historyText: expressApi.formatAllowedEdits(image.name, elementsWithCurrentEdits), }; } catch (err) { diff --git a/src/app.js b/src/app.js index b27d607925..9251ef1bf5 100644 --- a/src/app.js +++ b/src/app.js @@ -92,6 +92,23 @@ async function sendEditOptions(to, { bodyText, options }) { } } +// Edit option button/list-row ids are `edit:${imageId}:${fieldName}` (see +// expressApi.buildEditOptions). Parse that back out so a tap can tell GPT exactly +// which image and field the user picked, instead of only the truncated button title. +function parseEditOptionId(id) { + if (typeof id !== 'string' || !id.startsWith('edit:')) return null; + const rest = id.slice('edit:'.length); + const separatorIndex = rest.indexOf(':'); + if (separatorIndex === -1) return null; + return { imageId: rest.slice(0, separatorIndex), fieldName: rest.slice(separatorIndex + 1) }; +} + +function messageTextForInteractiveReply(reply) { + const parsed = parseEditOptionId(reply.id); + if (!parsed) return reply.title; + return `I'd like to change "${parsed.fieldName}" on image ${parsed.imageId}.`; +} + // ── GPT tool definitions ───────────────────────────────────────────────────── const tools = [ @@ -180,6 +197,7 @@ async function decideAction(phoneNumber, userMessage) { Analyze the user's message and conversation history, then call the appropriate tool. Always call exactly one tool — never reply with plain text. If the request is ambiguous or missing details, use ask_for_more_information. +If the user says which field they want to change but hasn't given the new value yet, call ask_for_more_information to ask what to change it to. If a later message in the conversation then supplies that value, call edit_graphic with the field and value instead of asking again. If the user asks to translate a tag's text into another language (e.g. "change the headline to Hindi"), translate the current text yourself before calling edit_graphic and pass the translated text as the edit value. For Hindi, the translation must be in Devanagari script (e.g. "उपलब्ध"), not a romanized/transliterated form. Images previously sent to this user (reference by id): @@ -232,7 +250,7 @@ app.post('/', async (req, res) => { if (message.image) console.log('[webhook] message.image:', JSON.stringify(message.image)); const interactiveReply = message?.interactive?.button_reply || message?.interactive?.list_reply; - const userText = message?.text?.body || interactiveReply?.title; + const userText = message?.text?.body || (interactiveReply && messageTextForInteractiveReply(interactiveReply)); if (!userText) continue; const phoneNumber = message.from; diff --git a/src/express/expressApi.js b/src/express/expressApi.js index cb7ff9a857..9cc154df9c 100644 --- a/src/express/expressApi.js +++ b/src/express/expressApi.js @@ -99,9 +99,9 @@ function truncateTitle(title, maxLength = 20) { return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated; } -function buildEditOptions(elements) { +function buildEditOptions(elements, imageId) { return elements.map((element) => ({ - id: `edit:${element.name}`, + id: `edit:${imageId}:${element.name}`, title: truncateTitle(`Change ${humanizeFieldName(element.name)}`), })); } From 63c00ab1a493152fa177bee1febb9ff2eef6a227 Mon Sep 17 00:00:00 2001 From: Priyank Modi Date: Wed, 22 Jul 2026 11:24:29 +0530 Subject: [PATCH 17/35] Add design doc for TV product-swap quick-reply flow Co-Authored-By: Claude Sonnet 5 --- .../2026-07-22-tv-product-swap-design.md | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-22-tv-product-swap-design.md diff --git a/docs/superpowers/specs/2026-07-22-tv-product-swap-design.md b/docs/superpowers/specs/2026-07-22-tv-product-swap-design.md new file mode 100644 index 0000000000..7ecbc2fc86 --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-tv-product-swap-design.md @@ -0,0 +1,75 @@ +# Design: "Change product to TV" quick-reply flow + +## Goal + +Support this WhatsApp conversation flow: + +1. User asks to change the product in a graphic to a TV (e.g. "change product to tv"), without naming a specific model. +2. System asks "Which model would you like to use?" with 3 tappable quick-reply buttons: Sony Bravia K-75 / LG UA82 AI / Samsung UA4. +3. User taps one. +4. System immediately calls the Adobe Express API to apply all of: `productImage` → the TV's image, `oldPrice` → 33999, `price` → 27199. No further clarifying question — the tap alone is enough to act. +5. System sends the updated image, same as any other edit. + +Scope is TV-only for now: one hardcoded trigger, one hardcoded list of 3 models, and (until real per-model assets exist) the same placeholder image URL and the same fixed `oldPrice`/`price` for all three buttons. Not designed as a general "product category" system — if a second category is needed later, this gets generalized then. + +## New GPT tool + +Added to the existing `tools` array in `app.js`, following the same shape as `check_allowed_edits`/`edit_graphic`: + +```js +select_tv_model({ image_id }) +``` + +Description: "Use when the user asks to change/set the product in a graphic to a TV, without specifying which model. Do not use this for edits to text fields — use edit_graphic for those." `image_id` is picked from the tracked-images list exactly like the other tools. + +## Action handler + +New `actionSelectTvModel(phoneNumber, imageId)` in `actions.js`, returning the same `{ type: 'edit_options', bodyText, options }` shape `actionCheckAllowedEdits` already returns, so it reuses `sendEditOptions` unchanged (3 options → one native button message, no "More edits" chunking). + +```js +const TV_MODELS = [ + { title: 'Sony Bravia K-75' }, + { title: 'LG UA82 AI' }, + { title: 'Samsung UA4' }, +]; +const TV_PLACEHOLDER_IMAGE_URL = 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000'; +const TV_EDITS = { productImage: TV_PLACEHOLDER_IMAGE_URL, oldPrice: 33999, price: 27199 }; +``` + +`bodyText`: `"Which model would you like to use?"`. Each option's `id` encodes the *full* edits object (not just a field name), since the tap must fully specify the change — see below. + +No `findTrackedImage`/Express lookup is needed at this step (unlike `check_allowed_edits`, which reads the real tagged document to build its option list); the 3 buttons are fixed regardless of the document. Validation still happens later, for free, in `actionEditGraphic`. + +## Button `id` encoding (extends the existing mechanism) + +Today, edit-option button ids are `edit::` (bare field, no value — tapping always leads to a clarifying question, per `app.js` `parseEditOptionId`/`messageTextForInteractiveReply`). + +The TV buttons need to carry a *value* too, and here it's actually multiple field/value pairs at once. A naive `field=value` suffix breaks because the image URL itself contains a literal `=` (`?wid=1000`), and there's more than one field. Instead, the remainder after `edit::` becomes a JSON-encoded edits object when a value is already known: + +``` +edit:: +``` + +`parseEditOptionId` is extended: after splitting off `imageId`, try `JSON.parse(decodeURIComponent(remainder))`. If it parses to a plain object, treat this as a **fully-specified edit** (`{ imageId, edits }`). If parsing throws, fall back to the existing behavior — remainder is a bare field name (`{ imageId, fieldName }`). + +`messageTextForInteractiveReply` is extended to match: +- Fully-specified (`edits` present): build a synthetic user message listing every field/value, e.g. `I'd like to change "productImage" to "https://...", "oldPrice" to "33999", "price" to "27199" on image img_1.` GPT already has a system-prompt rule that a fully-specified request should go straight to `edit_graphic` — no new prompt change needed here. +- Bare field (existing case): unchanged — asks GPT to prompt for the missing value. + +This keeps one general mechanism for "a button tap that already knows its value(s)" rather than adding TV-specific parsing. + +## Validation / error handling + +Unchanged and reused: when `edit_graphic` runs, `actionEditGraphic` already checks every requested key against the real tagged document's element names and rejects unknown ones with the existing friendly message. If `productImage`/`oldPrice`/`price` aren't real tags on a given document, the user gets that existing rejection message — no new error handling to write. + +## Testing + +- Unit test for the extended `parseEditOptionId`/`messageTextForInteractiveReply`: bare-field id still parses as before; a JSON-encoded multi-field id parses to `{ imageId, edits }` and produces a message listing all fields/values. +- Unit test for `actionSelectTvModel`: returns 3 options, each titled with a model name, each `id` decodable back to `{ productImage, oldPrice, price }` with the expected values. +- Manual/simulated webhook check: "change product to tv" → 3 buttons in one message → tap one → `edit_graphic` called with all 3 fields → thumbnail sent. + +## Out of scope + +- Per-model images or pricing (all 3 buttons currently share the same values). +- Any product category other than TV. +- A data-driven/config-file catalog of product categories — revisit if a second category is actually needed. From 574f1938586840cd7749636f5fe96e4507858b9b Mon Sep 17 00:00:00 2001 From: Priyank Modi Date: Wed, 22 Jul 2026 13:02:52 +0530 Subject: [PATCH 18/35] Add implementation plan for TV product-swap quick-reply flow Co-Authored-By: Claude Sonnet 5 --- .../plans/2026-07-22-tv-product-swap.md | 494 ++++++++++++++++++ 1 file changed, 494 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-22-tv-product-swap.md diff --git a/docs/superpowers/plans/2026-07-22-tv-product-swap.md b/docs/superpowers/plans/2026-07-22-tv-product-swap.md new file mode 100644 index 0000000000..732e06911e --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-tv-product-swap.md @@ -0,0 +1,494 @@ +# TV Product-Swap Quick-Reply Flow Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** When a user asks to change the product in a graphic to a TV, show 3 quick-reply model buttons, and tapping one immediately applies `productImage`/`oldPrice`/`price` via the real Adobe Express edit pipeline — no follow-up question needed. + +**Architecture:** Extract the existing "interactive reply id" encode/parse logic out of `app.js` into a new small pure module (`src/interactiveReply.js`) so it's unit-testable and reusable, extend it to support fully-specified multi-field edits (JSON-encoded in the id, since the image URL contains a literal `=`), add a new `select_tv_model` GPT tool + `actionSelectTvModel` handler that reuses the existing `sendEditOptions` button-rendering path, and let the existing `edit_graphic` tool + `actionEditGraphic` validation handle the rest unchanged. + +**Tech Stack:** Node.js, `node:test` + `node:assert/strict` (existing test runner, no new dependencies), Express, OpenAI SDK (tool-calling), WhatsApp Cloud API. + +## Global Constraints + +- No new npm dependencies — use `node:test`/`node:assert/strict` exactly like every existing `*.test.js` file. +- `app.js` cannot be `require()`'d from a test file — it calls `app.listen(port)` at module load time with no test guard, so any logic that needs unit tests must live in a module that doesn't import `app.js`. +- All 3 TV model buttons apply the exact same fixed edits for now: `{ productImage: 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000', oldPrice: 33999, price: 27199 }` — not per-model values. +- Model titles, verbatim: `Sony Bravia K-75`, `LG UA82 AI`, `Samsung UA4`. +- Question body text, verbatim: `Which model would you like to use?` +- Keep using the existing `edit_graphic`/`actionEditGraphic` validation path for applying the edit — do not duplicate or bypass its allowed-tag-name check. + +--- + +### Task 1: Extract interactive-reply id parsing into a testable module + +**Files:** +- Create: `src/interactiveReply.js` +- Create: `src/interactiveReply.test.js` +- Modify: `src/app.js:95-110` (remove the two functions being extracted), `src/app.js:1-9` (add require), `src/app.js:252-253` (use the imported function — no logic change) + +**Interfaces:** +- Produces: `parseEditOptionId(id: string) => { imageId: string, fieldName: string } | null` (same behavior as today), `messageTextForInteractiveReply(reply: { id: string, title: string }) => string` (same behavior as today). + +This task is a pure refactor — behavior must not change. It exists so Task 2 can extend this logic with real unit tests, since `app.js` itself can't be safely required in a test file (see Global Constraints). + +- [ ] **Step 1: Write the failing test file** + +Create `src/interactiveReply.test.js`: + +```js +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { parseEditOptionId, messageTextForInteractiveReply } = require('./interactiveReply'); + +test('parseEditOptionId parses a bare field-only id', () => { + const parsed = parseEditOptionId('edit:img_1:heading'); + assert.deepEqual(parsed, { imageId: 'img_1', fieldName: 'heading' }); +}); + +test('parseEditOptionId returns null for an id with no edit: prefix', () => { + assert.equal(parseEditOptionId('something_else'), null); +}); + +test('parseEditOptionId returns null for an id missing the field separator', () => { + assert.equal(parseEditOptionId('edit:img_1'), null); +}); + +test('parseEditOptionId returns null for a non-string id', () => { + assert.equal(parseEditOptionId(undefined), null); +}); + +test('messageTextForInteractiveReply builds a change-field message for a bare field id', () => { + const text = messageTextForInteractiveReply({ id: 'edit:img_1:heading', title: 'Change heading' }); + assert.equal(text, 'I\'d like to change "heading" on image img_1.'); +}); + +test('messageTextForInteractiveReply falls back to the title when the id is unparseable', () => { + const text = messageTextForInteractiveReply({ id: 'not-an-edit-id', title: 'Some Title' }); + assert.equal(text, 'Some Title'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test src/interactiveReply.test.js` +Expected: FAIL — `Cannot find module './interactiveReply'` + +- [ ] **Step 3: Create the module by moving the existing logic out of app.js** + +Create `src/interactiveReply.js`: + +```js +// Edit option button/list-row ids are `edit:${imageId}:${fieldName}` (see +// expressApi.buildEditOptions). Parse that back out so a tap can tell GPT exactly +// which image and field the user picked, instead of only the truncated button title. +function parseEditOptionId(id) { + if (typeof id !== 'string' || !id.startsWith('edit:')) return null; + const rest = id.slice('edit:'.length); + const separatorIndex = rest.indexOf(':'); + if (separatorIndex === -1) return null; + return { imageId: rest.slice(0, separatorIndex), fieldName: rest.slice(separatorIndex + 1) }; +} + +function messageTextForInteractiveReply(reply) { + const parsed = parseEditOptionId(reply.id); + if (!parsed) return reply.title; + return `I'd like to change "${parsed.fieldName}" on image ${parsed.imageId}.`; +} + +module.exports = { parseEditOptionId, messageTextForInteractiveReply }; +``` + +Now remove the two functions from `app.js:95-110` (the block starting with the `// Edit option button/list-row ids are...` comment and ending after the `messageTextForInteractiveReply` function), and instead require them at the top. Change `src/app.js:3-9` from: + +```js +const { getTrackedImages } = require('./imageStore'); +const { + actionListCampaignGraphics, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, +} = require('./actions'); +``` + +to: + +```js +const { getTrackedImages } = require('./imageStore'); +const { + actionListCampaignGraphics, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, +} = require('./actions'); +const { parseEditOptionId, messageTextForInteractiveReply } = require('./interactiveReply'); +``` + +`app.js:252-253` (the webhook loop) is unchanged — it already just calls `messageTextForInteractiveReply(interactiveReply)`, which now resolves to the imported function instead of a local one. `parseEditOptionId` is imported for use in Task 4's wiring even though nothing in `app.js` calls it directly yet after this task. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --test src/interactiveReply.test.js` +Expected: PASS (6 tests) + +- [ ] **Step 5: Run the full suite to confirm nothing else broke** + +Run: `node --test src/` +Expected: same pass/fail counts as before this task (32 pass, 2 pre-existing unrelated failures in `actions.test.js` — see Note below) + +> **Note:** `src/actions.test.js` has 2 pre-existing failing tests (`actionCheckAllowedEdits lists the tagged elements...` and `...shows the latest edited value...`) unrelated to this plan — they assert `reply` is a string, but `actionCheckAllowedEdits` has returned an object since an earlier commit. Do not fix them as part of this plan; just confirm the count doesn't change. + +- [ ] **Step 6: Commit** + +```bash +git add src/interactiveReply.js src/interactiveReply.test.js src/app.js +git commit -m "Extract interactive-reply id parsing into a testable module" +``` + +--- + +### Task 2: Support fully-specified multi-field edits in interactive reply ids + +**Files:** +- Modify: `src/interactiveReply.js` +- Modify: `src/interactiveReply.test.js` + +**Interfaces:** +- Consumes: nothing new. +- Produces: `buildValueEditId(imageId: string, edits: object) => string`, `parseEditOptionId` now also returns `{ imageId: string, edits: object }` (no `fieldName`) when the id encodes a full edits object, `messageTextForInteractiveReply` now also handles that shape. Task 3 (`actionSelectTvModel`) calls `buildValueEditId`. Task 4 (`app.js` webhook wiring) relies on `parseEditOptionId`/`messageTextForInteractiveReply` handling both shapes transparently — no change needed in `app.js` for this task. + +- [ ] **Step 1: Write the failing tests** + +Add to `src/interactiveReply.test.js`: + +```js +const { buildValueEditId, parseEditOptionId, messageTextForInteractiveReply } = require('./interactiveReply'); + +test('buildValueEditId round-trips through parseEditOptionId', () => { + const edits = { productImage: 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000', oldPrice: 33999, price: 27199 }; + const id = buildValueEditId('img_1', edits); + const parsed = parseEditOptionId(id); + assert.deepEqual(parsed, { imageId: 'img_1', edits }); +}); + +test('parseEditOptionId still parses a bare field-only id after the value-edit change', () => { + const parsed = parseEditOptionId('edit:img_1:heading'); + assert.deepEqual(parsed, { imageId: 'img_1', fieldName: 'heading' }); +}); + +test('messageTextForInteractiveReply lists every field/value for a fully-specified edit id', () => { + const id = buildValueEditId('img_1', { productImage: 'https://example.com/tv.png', oldPrice: 33999, price: 27199 }); + const text = messageTextForInteractiveReply({ id, title: 'Sony Bravia K-75' }); + assert.equal( + text, + 'I\'d like to change "productImage" to "https://example.com/tv.png", "oldPrice" to "33999", "price" to "27199" on image img_1.' + ); +}); +``` + +(Update the existing `require` at the top of the test file to include `buildValueEditId` in the destructure — one `require` line covering all four exports is fine.) + +- [ ] **Step 2: Run tests to verify the new ones fail** + +Run: `node --test src/interactiveReply.test.js` +Expected: FAIL — `buildValueEditId is not a function` (and the round-trip/message tests fail too) + +- [ ] **Step 3: Implement the extension** + +Replace the contents of `src/interactiveReply.js` with: + +```js +const EDIT_ID_PREFIX = 'edit:'; + +// Edit option button/list-row ids are `edit:${imageId}:${fieldName}` (bare field — +// see expressApi.buildEditOptions) or `edit:${imageId}:${encodeURIComponent(JSON.stringify(edits))}` +// (fully-specified — see actions.buildTvModelOptions) — parse either shape back out so +// a tap can tell GPT exactly what to do instead of only the truncated button title. +function buildValueEditId(imageId, edits) { + return `${EDIT_ID_PREFIX}${imageId}:${encodeURIComponent(JSON.stringify(edits))}`; +} + +function parseEditOptionId(id) { + if (typeof id !== 'string' || !id.startsWith(EDIT_ID_PREFIX)) return null; + const rest = id.slice(EDIT_ID_PREFIX.length); + const separatorIndex = rest.indexOf(':'); + if (separatorIndex === -1) return null; + const imageId = rest.slice(0, separatorIndex); + const remainder = rest.slice(separatorIndex + 1); + + try { + const decoded = JSON.parse(decodeURIComponent(remainder)); + if (decoded && typeof decoded === 'object' && !Array.isArray(decoded)) { + return { imageId, edits: decoded }; + } + } catch { + // Not JSON — remainder is a bare field name, handled below. + } + + return { imageId, fieldName: remainder }; +} + +function describeEdits(edits) { + return Object.entries(edits) + .map(([key, value]) => `"${key}" to "${value}"`) + .join(', '); +} + +function messageTextForInteractiveReply(reply) { + const parsed = parseEditOptionId(reply.id); + if (!parsed) return reply.title; + if (parsed.edits) { + return `I'd like to change ${describeEdits(parsed.edits)} on image ${parsed.imageId}.`; + } + return `I'd like to change "${parsed.fieldName}" on image ${parsed.imageId}.`; +} + +module.exports = { buildValueEditId, parseEditOptionId, messageTextForInteractiveReply }; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test src/interactiveReply.test.js` +Expected: PASS (9 tests) + +- [ ] **Step 5: Run the full suite** + +Run: `node --test src/` +Expected: same as Task 1's Step 5 (no new failures) + +- [ ] **Step 6: Commit** + +```bash +git add src/interactiveReply.js src/interactiveReply.test.js +git commit -m "Support fully-specified multi-field edits in interactive reply ids" +``` + +--- + +### Task 3: Add the TV model options handler + +**Files:** +- Modify: `src/actions.js` +- Modify: `src/actions.test.js` + +**Interfaces:** +- Consumes: `buildValueEditId(imageId, edits)` from `./interactiveReply` (Task 2). +- Produces: `actionSelectTvModel(imageId) => { type: 'edit_options', bodyText: string, options: Array<{ id: string, title: string }> }` (synchronous — no Express API call needed to build the fixed 3-option list). Task 4 (`app.js` webhook wiring) calls this and passes the result straight into the existing `sendEditOptions(phoneNumber, result)` — same shape `actionCheckAllowedEdits` already produces. + +- [ ] **Step 1: Write the failing tests** + +Add to `src/actions.test.js` (add `actionSelectTvModel` to the existing `require('./actions')` destructure, and add `parseEditOptionId` from `./interactiveReply` for assertions): + +```js +const { actionCheckAllowedEdits, actionEditGraphic, actionSelectTvModel } = require('./actions'); +const { parseEditOptionId } = require('./interactiveReply'); + +test('actionSelectTvModel returns the 3 fixed TV model options with the question body text', () => { + const result = actionSelectTvModel('img_1'); + + assert.equal(result.type, 'edit_options'); + assert.equal(result.bodyText, 'Which model would you like to use?'); + assert.equal(result.options.length, 3); + assert.deepEqual( + result.options.map((option) => option.title), + ['Sony Bravia K-75', 'LG UA82 AI', 'Samsung UA4'] + ); +}); + +test('actionSelectTvModel encodes the same fixed productImage/oldPrice/price edits into every option id', () => { + const result = actionSelectTvModel('img_1'); + + for (const option of result.options) { + const parsed = parseEditOptionId(option.id); + assert.deepEqual(parsed, { + imageId: 'img_1', + edits: { + productImage: 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000', + oldPrice: 33999, + price: 27199, + }, + }); + } +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `node --test src/actions.test.js` +Expected: FAIL — `actionSelectTvModel is not a function` + +- [ ] **Step 3: Implement `actionSelectTvModel`** + +In `src/actions.js`, add near the top (after the existing `require`s at `src/actions.js:1-2`): + +```js +const { buildValueEditId } = require('./interactiveReply'); + +const TV_PLACEHOLDER_IMAGE_URL = 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000'; +const TV_MODEL_TITLES = ['Sony Bravia K-75', 'LG UA82 AI', 'Samsung UA4']; +const TV_MODEL_EDITS = { productImage: TV_PLACEHOLDER_IMAGE_URL, oldPrice: 33999, price: 27199 }; +``` + +Then add the function itself (near `actionCheckAllowedEdits`, e.g. directly after it): + +```js +function actionSelectTvModel(imageId) { + return { + type: 'edit_options', + bodyText: 'Which model would you like to use?', + options: TV_MODEL_TITLES.map((title) => ({ + id: buildValueEditId(imageId, TV_MODEL_EDITS), + title, + })), + }; +} +``` + +Finally, add `actionSelectTvModel` to `module.exports` at the bottom of `src/actions.js`: + +```js +module.exports = { + actionListCampaignGraphics, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, + actionSelectTvModel, +}; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test src/actions.test.js` +Expected: PASS for the 2 new tests (the 2 pre-existing unrelated failures from Task 1's Note still fail — that's expected, don't touch them) + +- [ ] **Step 5: Run the full suite** + +Run: `node --test src/` +Expected: 34 pass, 2 pre-existing unrelated failures (same 2 as before — count of passing tests increases by 2 vs. Task 2's baseline) + +- [ ] **Step 6: Commit** + +```bash +git add src/actions.js src/actions.test.js +git commit -m "Add actionSelectTvModel handler for the TV product-swap quick replies" +``` + +--- + +### Task 4: Wire the `select_tv_model` GPT tool into the webhook + +**Files:** +- Modify: `src/app.js` + +**Interfaces:** +- Consumes: `actionSelectTvModel(imageId)` from `./actions` (Task 3). +- Produces: nothing new for later tasks — this is the last piece of wiring. + +- [ ] **Step 1: Import `actionSelectTvModel`** + +In `src/app.js:4-9`, change: + +```js +const { + actionListCampaignGraphics, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, +} = require('./actions'); +``` + +to: + +```js +const { + actionListCampaignGraphics, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, + actionSelectTvModel, +} = require('./actions'); +``` + +- [ ] **Step 2: Add the `select_tv_model` tool definition** + +In `src/app.js`, in the `tools` array (currently `src/app.js:114-184`), add a new entry. Insert it right after the `check_allowed_edits` tool definition (ends at `src/app.js:151`) and before `edit_graphic`: + +```js + { + type: 'function', + function: { + name: 'select_tv_model', + description: + 'Use when the user asks to change or set the product in a graphic to a TV, without specifying which model. Do not use this for edits to text fields or other product types — use edit_graphic for those.', + parameters: { + type: 'object', + properties: { + image_id: { type: 'string', description: 'The id of the image to edit, from the tracked images list' }, + }, + required: ['image_id'], + }, + }, + }, +``` + +- [ ] **Step 3: Handle the tool call in the webhook switch statement** + +In `src/app.js`, the `switch (action)` block (currently `src/app.js:272-307`) has a `case 'check_allowed_edits':` block (`src/app.js:282-293`) that shows the pattern to follow — sending a placeholder text, calling the action, then rendering options via `sendEditOptions` and setting `skipSend = true`. Add a new case right after it: + +```js + case 'select_tv_model': { + const result = actionSelectTvModel(args.image_id); + await sendEditOptions(phoneNumber, result); + replyText = result.bodyText; + skipSend = true; + break; + } +``` + +(No "⏳ ..." placeholder text before this one — unlike `check_allowed_edits`/`edit_graphic`, it's synchronous and doesn't call the Express API, so there's nothing to wait on.) + +- [ ] **Step 4: Run the full test suite** + +Run: `node --test src/` +Expected: same pass/fail counts as Task 3's Step 5 (this task only touches `app.js`, which has no automated tests — see Global Constraints) + +- [ ] **Step 5: Manual verification via a local script** + +`app.js` can't be imported in a test, so verify the new wiring by exercising the pieces it composes directly in a scratch script. Run this with `node`: + +```js +const { actionSelectTvModel, actionEditGraphic } = require('./src/actions'); +const { parseEditOptionId, messageTextForInteractiveReply } = require('./src/interactiveReply'); + +// 1. Simulate GPT calling select_tv_model('img_1') after "change product to tv": +const result = actionSelectTvModel('img_1'); +console.log('Options shown to user:', result.options); + +// 2. Simulate the user tapping the first button — this is what the webhook receives: +const tappedReply = { id: result.options[0].id, title: result.options[0].title }; + +// 3. Simulate app.js turning that tap into a synthetic message for GPT: +console.log('Synthetic message sent to GPT:', messageTextForInteractiveReply(tappedReply)); + +// 4. Confirm parseEditOptionId recovers the exact edits object actionEditGraphic will receive: +console.log('Parsed edits:', parseEditOptionId(tappedReply.id)); +``` + +Expected output: +- `Options shown to user` has 3 entries titled `Sony Bravia K-75`, `LG UA82 AI`, `Samsung UA4`. +- `Synthetic message sent to GPT` reads: `I'd like to change "productImage" to "https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000", "oldPrice" to "33999", "price" to "27199" on image img_1.` +- `Parsed edits` is `{ imageId: 'img_1', edits: { productImage: '...', oldPrice: 33999, price: 27199 } }`. + +This confirms the full chain (options → tap → synthetic GPT message → recovered edits) works end to end without needing a live WhatsApp/OpenAI/Express connection. The remaining link — GPT actually calling `edit_graphic` with this synthetic message, and `actionEditGraphic` calling the real Express API — is already covered by the existing `edit_graphic` system-prompt rule and `actions.test.js` coverage; it doesn't need TV-specific testing since nothing about it is TV-specific. + +- [ ] **Step 6: Commit** + +```bash +git add src/app.js +git commit -m "Wire select_tv_model GPT tool into the webhook handler" +``` + +--- + +## Post-plan check + +After Task 4, run `node --test src/` one final time and confirm: the only failures are the 2 pre-existing ones already present before this plan started (`actionCheckAllowedEdits lists the tagged elements for a known image` and `actionCheckAllowedEdits shows the latest edited value instead of the stale original document value`). If any other test fails, stop and investigate before considering this plan done. From aa16c25551569270569ac129c00218494a4accbd Mon Sep 17 00:00:00 2001 From: Priyank Modi Date: Wed, 22 Jul 2026 13:05:17 +0530 Subject: [PATCH 19/35] Extract interactive-reply id parsing into a testable module --- src/app.js | 18 +----------------- src/interactiveReply.js | 18 ++++++++++++++++++ src/interactiveReply.test.js | 30 ++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 17 deletions(-) create mode 100644 src/interactiveReply.js create mode 100644 src/interactiveReply.test.js diff --git a/src/app.js b/src/app.js index 9251ef1bf5..9ce75c633e 100644 --- a/src/app.js +++ b/src/app.js @@ -7,6 +7,7 @@ const { actionEditGraphic, actionGenerateBulkGraphics, } = require('./actions'); +const { parseEditOptionId, messageTextForInteractiveReply } = require('./interactiveReply'); const app = express(); app.use(express.json()); @@ -92,23 +93,6 @@ async function sendEditOptions(to, { bodyText, options }) { } } -// Edit option button/list-row ids are `edit:${imageId}:${fieldName}` (see -// expressApi.buildEditOptions). Parse that back out so a tap can tell GPT exactly -// which image and field the user picked, instead of only the truncated button title. -function parseEditOptionId(id) { - if (typeof id !== 'string' || !id.startsWith('edit:')) return null; - const rest = id.slice('edit:'.length); - const separatorIndex = rest.indexOf(':'); - if (separatorIndex === -1) return null; - return { imageId: rest.slice(0, separatorIndex), fieldName: rest.slice(separatorIndex + 1) }; -} - -function messageTextForInteractiveReply(reply) { - const parsed = parseEditOptionId(reply.id); - if (!parsed) return reply.title; - return `I'd like to change "${parsed.fieldName}" on image ${parsed.imageId}.`; -} - // ── GPT tool definitions ───────────────────────────────────────────────────── const tools = [ diff --git a/src/interactiveReply.js b/src/interactiveReply.js new file mode 100644 index 0000000000..e81e0c3ab6 --- /dev/null +++ b/src/interactiveReply.js @@ -0,0 +1,18 @@ +// Edit option button/list-row ids are `edit:${imageId}:${fieldName}` (see +// expressApi.buildEditOptions). Parse that back out so a tap can tell GPT exactly +// which image and field the user picked, instead of only the truncated button title. +function parseEditOptionId(id) { + if (typeof id !== 'string' || !id.startsWith('edit:')) return null; + const rest = id.slice('edit:'.length); + const separatorIndex = rest.indexOf(':'); + if (separatorIndex === -1) return null; + return { imageId: rest.slice(0, separatorIndex), fieldName: rest.slice(separatorIndex + 1) }; +} + +function messageTextForInteractiveReply(reply) { + const parsed = parseEditOptionId(reply.id); + if (!parsed) return reply.title; + return `I'd like to change "${parsed.fieldName}" on image ${parsed.imageId}.`; +} + +module.exports = { parseEditOptionId, messageTextForInteractiveReply }; diff --git a/src/interactiveReply.test.js b/src/interactiveReply.test.js new file mode 100644 index 0000000000..b0476db9f2 --- /dev/null +++ b/src/interactiveReply.test.js @@ -0,0 +1,30 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { parseEditOptionId, messageTextForInteractiveReply } = require('./interactiveReply'); + +test('parseEditOptionId parses a bare field-only id', () => { + const parsed = parseEditOptionId('edit:img_1:heading'); + assert.deepEqual(parsed, { imageId: 'img_1', fieldName: 'heading' }); +}); + +test('parseEditOptionId returns null for an id with no edit: prefix', () => { + assert.equal(parseEditOptionId('something_else'), null); +}); + +test('parseEditOptionId returns null for an id missing the field separator', () => { + assert.equal(parseEditOptionId('edit:img_1'), null); +}); + +test('parseEditOptionId returns null for a non-string id', () => { + assert.equal(parseEditOptionId(undefined), null); +}); + +test('messageTextForInteractiveReply builds a change-field message for a bare field id', () => { + const text = messageTextForInteractiveReply({ id: 'edit:img_1:heading', title: 'Change heading' }); + assert.equal(text, 'I\'d like to change "heading" on image img_1.'); +}); + +test('messageTextForInteractiveReply falls back to the title when the id is unparseable', () => { + const text = messageTextForInteractiveReply({ id: 'not-an-edit-id', title: 'Some Title' }); + assert.equal(text, 'Some Title'); +}); From f766815f52d197bf182ad939d95ecca51e8145a0 Mon Sep 17 00:00:00 2001 From: Priyank Modi Date: Wed, 22 Jul 2026 13:06:04 +0530 Subject: [PATCH 20/35] Support fully-specified multi-field edits in interactive reply ids --- src/interactiveReply.js | 42 ++++++++++++++++++++++++++++++------ src/interactiveReply.test.js | 23 +++++++++++++++++++- 2 files changed, 57 insertions(+), 8 deletions(-) diff --git a/src/interactiveReply.js b/src/interactiveReply.js index e81e0c3ab6..d7aa55a63f 100644 --- a/src/interactiveReply.js +++ b/src/interactiveReply.js @@ -1,18 +1,46 @@ -// Edit option button/list-row ids are `edit:${imageId}:${fieldName}` (see -// expressApi.buildEditOptions). Parse that back out so a tap can tell GPT exactly -// which image and field the user picked, instead of only the truncated button title. +const EDIT_ID_PREFIX = 'edit:'; + +// Edit option button/list-row ids are `edit:${imageId}:${fieldName}` (bare field — +// see expressApi.buildEditOptions) or `edit:${imageId}:${encodeURIComponent(JSON.stringify(edits))}` +// (fully-specified — see actions.actionSelectTvModel) — parse either shape back out so +// a tap can tell GPT exactly what to do instead of only the truncated button title. +function buildValueEditId(imageId, edits) { + return `${EDIT_ID_PREFIX}${imageId}:${encodeURIComponent(JSON.stringify(edits))}`; +} + function parseEditOptionId(id) { - if (typeof id !== 'string' || !id.startsWith('edit:')) return null; - const rest = id.slice('edit:'.length); + if (typeof id !== 'string' || !id.startsWith(EDIT_ID_PREFIX)) return null; + const rest = id.slice(EDIT_ID_PREFIX.length); const separatorIndex = rest.indexOf(':'); if (separatorIndex === -1) return null; - return { imageId: rest.slice(0, separatorIndex), fieldName: rest.slice(separatorIndex + 1) }; + const imageId = rest.slice(0, separatorIndex); + const remainder = rest.slice(separatorIndex + 1); + + try { + const decoded = JSON.parse(decodeURIComponent(remainder)); + if (decoded && typeof decoded === 'object' && !Array.isArray(decoded)) { + return { imageId, edits: decoded }; + } + } catch { + // Not JSON — remainder is a bare field name, handled below. + } + + return { imageId, fieldName: remainder }; +} + +function describeEdits(edits) { + return Object.entries(edits) + .map(([key, value]) => `"${key}" to "${value}"`) + .join(', '); } function messageTextForInteractiveReply(reply) { const parsed = parseEditOptionId(reply.id); if (!parsed) return reply.title; + if (parsed.edits) { + return `I'd like to change ${describeEdits(parsed.edits)} on image ${parsed.imageId}.`; + } return `I'd like to change "${parsed.fieldName}" on image ${parsed.imageId}.`; } -module.exports = { parseEditOptionId, messageTextForInteractiveReply }; +module.exports = { buildValueEditId, parseEditOptionId, messageTextForInteractiveReply }; diff --git a/src/interactiveReply.test.js b/src/interactiveReply.test.js index b0476db9f2..04a62c7e6b 100644 --- a/src/interactiveReply.test.js +++ b/src/interactiveReply.test.js @@ -1,6 +1,6 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { parseEditOptionId, messageTextForInteractiveReply } = require('./interactiveReply'); +const { buildValueEditId, parseEditOptionId, messageTextForInteractiveReply } = require('./interactiveReply'); test('parseEditOptionId parses a bare field-only id', () => { const parsed = parseEditOptionId('edit:img_1:heading'); @@ -28,3 +28,24 @@ test('messageTextForInteractiveReply falls back to the title when the id is unpa const text = messageTextForInteractiveReply({ id: 'not-an-edit-id', title: 'Some Title' }); assert.equal(text, 'Some Title'); }); + +test('buildValueEditId round-trips through parseEditOptionId', () => { + const edits = { productImage: 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000', oldPrice: 33999, price: 27199 }; + const id = buildValueEditId('img_1', edits); + const parsed = parseEditOptionId(id); + assert.deepEqual(parsed, { imageId: 'img_1', edits }); +}); + +test('parseEditOptionId still parses a bare field-only id after the value-edit change', () => { + const parsed = parseEditOptionId('edit:img_1:heading'); + assert.deepEqual(parsed, { imageId: 'img_1', fieldName: 'heading' }); +}); + +test('messageTextForInteractiveReply lists every field/value for a fully-specified edit id', () => { + const id = buildValueEditId('img_1', { productImage: 'https://example.com/tv.png', oldPrice: 33999, price: 27199 }); + const text = messageTextForInteractiveReply({ id, title: 'Sony Bravia K-75' }); + assert.equal( + text, + 'I\'d like to change "productImage" to "https://example.com/tv.png", "oldPrice" to "33999", "price" to "27199" on image img_1.' + ); +}); From 30776d643458f77505b9a9e5ab52b250fac105ce Mon Sep 17 00:00:00 2001 From: Priyank Modi Date: Wed, 22 Jul 2026 13:06:57 +0530 Subject: [PATCH 21/35] Add actionSelectTvModel handler for the TV product-swap quick replies --- src/actions.js | 17 +++++++++++++++++ src/actions.test.js | 31 ++++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/actions.js b/src/actions.js index 7742eb2567..2e150f2ba7 100644 --- a/src/actions.js +++ b/src/actions.js @@ -1,5 +1,10 @@ const { getTrackedImages, findTrackedImage, recordEdits } = require('./imageStore'); const expressApi = require('./express/expressApi'); +const { buildValueEditId } = require('./interactiveReply'); + +const TV_PLACEHOLDER_IMAGE_URL = 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000'; +const TV_MODEL_TITLES = ['Sony Bravia K-75', 'LG UA82 AI', 'Samsung UA4']; +const TV_MODEL_EDITS = { productImage: TV_PLACEHOLDER_IMAGE_URL, oldPrice: 33999, price: 27199 }; function formatUnknownImageMessage(phoneNumber) { const images = getTrackedImages(phoneNumber); @@ -51,6 +56,17 @@ async function actionCheckAllowedEdits(phoneNumber, imageId) { } } +function actionSelectTvModel(imageId) { + return { + type: 'edit_options', + bodyText: 'Which model would you like to use?', + options: TV_MODEL_TITLES.map((title) => ({ + id: buildValueEditId(imageId, TV_MODEL_EDITS), + title, + })), + }; +} + async function actionEditGraphic(phoneNumber, imageId, edits, { sendImage }) { const image = findTrackedImage(phoneNumber, imageId); if (!image) { @@ -123,4 +139,5 @@ module.exports = { actionCheckAllowedEdits, actionEditGraphic, actionGenerateBulkGraphics, + actionSelectTvModel, }; diff --git a/src/actions.test.js b/src/actions.test.js index a662e30598..3d530851e3 100644 --- a/src/actions.test.js +++ b/src/actions.test.js @@ -3,9 +3,10 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); -const { actionCheckAllowedEdits, actionEditGraphic } = require('./actions'); +const { actionCheckAllowedEdits, actionEditGraphic, actionSelectTvModel } = require('./actions'); const expressApi = require('./express/expressApi'); const { findTrackedImage, recordEdits } = require('./imageStore'); +const { parseEditOptionId } = require('./interactiveReply'); function writeFixtureCatalog(entries) { const fixturePath = path.join(os.tmpdir(), `express-templates-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); @@ -147,3 +148,31 @@ test('actionEditGraphic tells the user delivery failed but keeps the recorded ed const image = findTrackedImage('phone-7', 'img_1'); assert.deepEqual(image.currentEdits, { cta: '20% off' }); }); + +test('actionSelectTvModel returns the 3 fixed TV model options with the question body text', () => { + const result = actionSelectTvModel('img_1'); + + assert.equal(result.type, 'edit_options'); + assert.equal(result.bodyText, 'Which model would you like to use?'); + assert.equal(result.options.length, 3); + assert.deepEqual( + result.options.map((option) => option.title), + ['Sony Bravia K-75', 'LG UA82 AI', 'Samsung UA4'] + ); +}); + +test('actionSelectTvModel encodes the same fixed productImage/oldPrice/price edits into every option id', () => { + const result = actionSelectTvModel('img_1'); + + for (const option of result.options) { + const parsed = parseEditOptionId(option.id); + assert.deepEqual(parsed, { + imageId: 'img_1', + edits: { + productImage: 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000', + oldPrice: 33999, + price: 27199, + }, + }); + } +}); From c6fdc568d0ae534aa1c9193b0b663739253540c0 Mon Sep 17 00:00:00 2001 From: Priyank Modi Date: Wed, 22 Jul 2026 13:07:43 +0530 Subject: [PATCH 22/35] Wire select_tv_model GPT tool into the webhook handler --- src/app.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/app.js b/src/app.js index 9ce75c633e..95ff45af3a 100644 --- a/src/app.js +++ b/src/app.js @@ -6,6 +6,7 @@ const { actionCheckAllowedEdits, actionEditGraphic, actionGenerateBulkGraphics, + actionSelectTvModel, } = require('./actions'); const { parseEditOptionId, messageTextForInteractiveReply } = require('./interactiveReply'); @@ -133,6 +134,21 @@ const tools = [ }, }, }, + { + type: 'function', + function: { + name: 'select_tv_model', + description: + 'Use when the user asks to change or set the product in a graphic to a TV, without specifying which model. Do not use this for edits to text fields or other product types — use edit_graphic for those.', + parameters: { + type: 'object', + properties: { + image_id: { type: 'string', description: 'The id of the image to edit, from the tracked images list' }, + }, + required: ['image_id'], + }, + }, + }, { type: 'function', function: { @@ -276,6 +292,14 @@ app.post('/', async (req, res) => { break; } + case 'select_tv_model': { + const result = actionSelectTvModel(args.image_id); + await sendEditOptions(phoneNumber, result); + replyText = result.bodyText; + skipSend = true; + break; + } + case 'edit_graphic': await sendText(phoneNumber, '⏳ Applying edits to your graphic...'); replyText = await actionEditGraphic(phoneNumber, args.image_id, args.edits, { sendImage }); From 9cf20d741d6cbed500a8864173197c3509274030 Mon Sep 17 00:00:00 2001 From: varun kalra Date: Wed, 22 Jul 2026 14:10:16 +0530 Subject: [PATCH 23/35] fix: make Onam translate/edit routing robust for the 3-message demo flow Supports the simplified Anjali flow (create -> add address -> translate to Malayalam) and fixes the case where "translate the banner to Malayalam" was rejected by the palette guardrail. - editLocalDesign: a Malayalam/translate request now short-circuits and maps straight to the Malayalam creative, bypassing the locked-field and palette guardrails (the model may place the translated text on any key, including one that looks like "background") - detect a translation request from Malayalam script or the word "Malayalam" in any edit key/value; mark language so resolveLocalImage picks the Malayalam image regardless of the edit key used - address-only edits still resolve to the final image - tests: cover address-only -> final, translate via unrecognized key, Malayalam under any key, and the background-key regression Co-Authored-By: Claude Opus 4.8 (1M context) --- data/onam-design.json | 6 ++--- src/actions.js | 53 +++++++++++++++++++++++++++++----------- src/createDesign.test.js | 44 +++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 17 deletions(-) diff --git a/data/onam-design.json b/data/onam-design.json index 5cc39839c9..0ff590537e 100644 --- a/data/onam-design.json +++ b/data/onam-design.json @@ -1,9 +1,9 @@ { "_comment": "Template used by the create_design tool for brand-new (source: 'local') creatives. Swap the placeholder image URLs below with your hosted PNG URLs. See README / actions.js for how they map to edit state.", "images": { - "base": "http://s7ap1.scene7.com/is/image/varunAEM/onam-base", - "final": "http://s7ap1.scene7.com/is/image/varunAEM/onam-final", - "malayalam": "http://s7ap1.scene7.com/is/image/varunAEM/onam-malyalam" + "base": "http://s7ap1.scene7.com/is/image/varunAEM/onam-base?cache=off", + "final": "http://s7ap1.scene7.com/is/image/varunAEM/onam-final?cache=off", + "malayalam": "http://s7ap1.scene7.com/is/image/varunAEM/onam-malyalam?cache=off" }, "palette": [ { "name": "Marigold", "hex": "#F4A300" }, diff --git a/src/actions.js b/src/actions.js index 4fdbfb0db5..75d4398441 100644 --- a/src/actions.js +++ b/src/actions.js @@ -89,9 +89,14 @@ function hasMalayalam(value) { } // Pick the canned image for the current accumulated edit state. +function isMalayalamEdit(currentEdits) { + return Object.entries(currentEdits).some(([key, value]) => + hasMalayalam(String(value)) || (key === 'language' && /malayalam/i.test(String(value))) + ); +} + function resolveLocalImage(design, currentEdits) { - const values = Object.values(currentEdits); - if (values.some(hasMalayalam)) return design.images.malayalam; + if (isMalayalamEdit(currentEdits)) return design.images.malayalam; if (currentEdits.background || currentEdits.address) return design.images.final; return design.images.base; } @@ -107,24 +112,44 @@ function localEditElements(image) { async function editLocalDesign(phoneNumber, image, rawEdits, { sendImage }) { const design = image.design; const editableSlots = design.slots.editable; - const { canonical: edits, unknown } = canonicalizeEdits(editableSlots, rawEdits); - if (unknown.length > 0) { - const editableNames = editableSlots.map((s) => s.name).join(', '); - return `I can't edit ${unknown.join(', ')} on "${image.name}" — those are locked by HQ. You can change: ${editableNames}.`; + // Translation is special: the model may pass the Malayalam text (or the word + // "Malayalam") under any key. Detect it up front so a "translate to Malayalam" + // request always maps to the Malayalam creative, regardless of the edit key. + const rawText = Object.entries(rawEdits || {}).flat().map(String); + const wantsMalayalam = rawText.some(hasMalayalam) || rawText.some((s) => /malayalam/i.test(s)); + + // A translation request short-circuits everything: map straight to the + // Malayalam creative. Never treat it as a field edit or run it through the + // palette / locked-field guardrails (the model may put the Malayalam text on + // any key, including one that looks like "background"). + let appliedEdits; + if (wantsMalayalam) { + appliedEdits = { language: 'Malayalam' }; + } else { + const { canonical: edits, unknown } = canonicalizeEdits(editableSlots, rawEdits); + + if (unknown.length > 0) { + const editableNames = editableSlots.map((s) => s.name).join(', '); + return `I can't edit ${unknown.join(', ')} on "${image.name}" — those are locked by HQ. You can change: ${editableNames}.`; + } + + if ('background' in edits && !isPaletteColor(design, edits.background)) { + const options = design.palette.map((c) => c.name).join(' · '); + return `"${edits.background}" isn't in the approved palette 🙂 Here are the festive accents you can pick from: ${options}`; + } + + appliedEdits = edits; } - if ('background' in edits && !isPaletteColor(design, edits.background)) { - const options = design.palette.map((c) => c.name).join(' · '); - return `"${edits.background}" isn't in the approved palette 🙂 Here are the festive accents you can pick from: ${options}`; - } - - recordEdits(phoneNumber, image.id, edits); - const currentEdits = { ...image.currentEdits, ...edits }; + recordEdits(phoneNumber, image.id, appliedEdits); + const currentEdits = { ...image.currentEdits, ...appliedEdits }; const imageUrl = resolveLocalImage(design, currentEdits); console.log('[edit:local] resolved image', { imageId: image.id, currentEdits, imageUrl }); - const summary = Object.entries(edits).map(([key, value]) => `• ${key}: ${value}`).join('\n'); + const summary = wantsMalayalam + ? '• translated to Malayalam' + : Object.entries(appliedEdits).map(([key, value]) => `• ${key}: ${value}`).join('\n'); try { await sendImage(phoneNumber, imageUrl); diff --git a/src/createDesign.test.js b/src/createDesign.test.js index da5adf0c54..5165ceff62 100644 --- a/src/createDesign.test.js +++ b/src/createDesign.test.js @@ -83,6 +83,50 @@ test('a Malayalam headline resolves to the Malayalam image', async () => { assert.equal(sent.at(-1), FIXTURE.images.malayalam); }); +test('adding only a store address resolves to the final image (demo msg 2)', async () => { + useFixture(); + const { sendImage, sent } = captureSendImage(); + await actionCreateDesign('onam-phone-a', { occasion: 'Onam' }, { sendImage }); + + await actionEditGraphic('onam-phone-a', 'local_1', { address: 'Princess Street, Kochi' }, { sendImage }); + + assert.equal(sent.at(-1), FIXTURE.images.final); +}); + +test('a translate request under an unrecognized key still resolves to Malayalam (demo msg 3)', async () => { + useFixture(); + const { sendImage, sent } = captureSendImage(); + await actionCreateDesign('onam-phone-b', { occasion: 'Onam' }, { sendImage }); + + // model phrases it as a language change rather than a headline edit + const reply = await actionEditGraphic('onam-phone-b', 'local_1', { language: 'Malayalam' }, { sendImage }); + + assert.equal(sent.at(-1), FIXTURE.images.malayalam); + assert.doesNotMatch(reply, /locked by HQ/); +}); + +test('a Malayalam value under any key resolves to Malayalam', async () => { + useFixture(); + const { sendImage, sent } = captureSendImage(); + await actionCreateDesign('onam-phone-c', { occasion: 'Onam' }, { sendImage }); + + await actionEditGraphic('onam-phone-c', 'local_1', { banner: 'ഓണം ആശംസകൾ' }, { sendImage }); + + assert.equal(sent.at(-1), FIXTURE.images.malayalam); +}); + +test('a Malayalam value landing on the background key translates, not palette-rejected (regression)', async () => { + useFixture(); + const { sendImage, sent } = captureSendImage(); + await actionCreateDesign('onam-phone-d', { occasion: 'Onam' }, { sendImage }); + + // model mistakenly puts the translated word on a "background"-like key + const reply = await actionEditGraphic('onam-phone-d', 'local_1', { background: 'ഓണം' }, { sendImage }); + + assert.equal(sent.at(-1), FIXTURE.images.malayalam); + assert.doesNotMatch(reply, /approved palette/); +}); + test('editing a locked element on a created design is refused', async () => { useFixture(); const { sendImage } = captureSendImage(); From ea0f7659c83f8a4cd3b644a5257f0fe4f5f6dd6e Mon Sep 17 00:00:00 2001 From: priyankmodiPM <32540484+priyankmodiPM@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:21:13 +0530 Subject: [PATCH 24/35] Feat/edit options quick reply buttons (#10) * Always render edit options as native reply buttons Replace the list-picker fallback with additional button messages (3 per message, WhatsApp's per-message cap) so options over 3 still show up as tappable buttons instead of a "Choose a field" dropdown. Co-Authored-By: Claude Sonnet 5 * Simplify edit-options prompt and button labels Show a short "What would you like to change?" prompt instead of dumping every field's current value into the button message body, and humanize camelCase field names (e.g. discountPercentage -> "Change discount") instead of only stripping _text/_image suffixes. The detailed field listing is still kept in conversation history for GPT context. Co-Authored-By: Claude Sonnet 5 * Wire quick-reply button taps to the actual field/image being edited Button/list-row ids now encode edit:: instead of just a display title, so tapping an edit option tells GPT precisely which field to edit instead of a truncated, ambiguous label. GPT now asks for the missing value and applies it via edit_graphic once supplied, rather than never reaching the Express API. Co-Authored-By: Claude Sonnet 5 * Add design doc for TV product-swap quick-reply flow Co-Authored-By: Claude Sonnet 5 * Add implementation plan for TV product-swap quick-reply flow Co-Authored-By: Claude Sonnet 5 * Extract interactive-reply id parsing into a testable module * Support fully-specified multi-field edits in interactive reply ids * Add actionSelectTvModel handler for the TV product-swap quick replies * Wire select_tv_model GPT tool into the webhook handler --------- Co-authored-by: Priyank Modi Co-authored-by: Claude Sonnet 5 --- .../plans/2026-07-22-tv-product-swap.md | 494 ++++++++++++++++++ .../2026-07-22-tv-product-swap-design.md | 75 +++ src/actions.js | 21 +- src/actions.test.js | 31 +- src/app.js | 28 +- src/express/expressApi.js | 4 +- src/interactiveReply.js | 46 ++ src/interactiveReply.test.js | 51 ++ 8 files changed, 744 insertions(+), 6 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-22-tv-product-swap.md create mode 100644 docs/superpowers/specs/2026-07-22-tv-product-swap-design.md create mode 100644 src/interactiveReply.js create mode 100644 src/interactiveReply.test.js diff --git a/docs/superpowers/plans/2026-07-22-tv-product-swap.md b/docs/superpowers/plans/2026-07-22-tv-product-swap.md new file mode 100644 index 0000000000..732e06911e --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-tv-product-swap.md @@ -0,0 +1,494 @@ +# TV Product-Swap Quick-Reply Flow Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** When a user asks to change the product in a graphic to a TV, show 3 quick-reply model buttons, and tapping one immediately applies `productImage`/`oldPrice`/`price` via the real Adobe Express edit pipeline — no follow-up question needed. + +**Architecture:** Extract the existing "interactive reply id" encode/parse logic out of `app.js` into a new small pure module (`src/interactiveReply.js`) so it's unit-testable and reusable, extend it to support fully-specified multi-field edits (JSON-encoded in the id, since the image URL contains a literal `=`), add a new `select_tv_model` GPT tool + `actionSelectTvModel` handler that reuses the existing `sendEditOptions` button-rendering path, and let the existing `edit_graphic` tool + `actionEditGraphic` validation handle the rest unchanged. + +**Tech Stack:** Node.js, `node:test` + `node:assert/strict` (existing test runner, no new dependencies), Express, OpenAI SDK (tool-calling), WhatsApp Cloud API. + +## Global Constraints + +- No new npm dependencies — use `node:test`/`node:assert/strict` exactly like every existing `*.test.js` file. +- `app.js` cannot be `require()`'d from a test file — it calls `app.listen(port)` at module load time with no test guard, so any logic that needs unit tests must live in a module that doesn't import `app.js`. +- All 3 TV model buttons apply the exact same fixed edits for now: `{ productImage: 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000', oldPrice: 33999, price: 27199 }` — not per-model values. +- Model titles, verbatim: `Sony Bravia K-75`, `LG UA82 AI`, `Samsung UA4`. +- Question body text, verbatim: `Which model would you like to use?` +- Keep using the existing `edit_graphic`/`actionEditGraphic` validation path for applying the edit — do not duplicate or bypass its allowed-tag-name check. + +--- + +### Task 1: Extract interactive-reply id parsing into a testable module + +**Files:** +- Create: `src/interactiveReply.js` +- Create: `src/interactiveReply.test.js` +- Modify: `src/app.js:95-110` (remove the two functions being extracted), `src/app.js:1-9` (add require), `src/app.js:252-253` (use the imported function — no logic change) + +**Interfaces:** +- Produces: `parseEditOptionId(id: string) => { imageId: string, fieldName: string } | null` (same behavior as today), `messageTextForInteractiveReply(reply: { id: string, title: string }) => string` (same behavior as today). + +This task is a pure refactor — behavior must not change. It exists so Task 2 can extend this logic with real unit tests, since `app.js` itself can't be safely required in a test file (see Global Constraints). + +- [ ] **Step 1: Write the failing test file** + +Create `src/interactiveReply.test.js`: + +```js +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { parseEditOptionId, messageTextForInteractiveReply } = require('./interactiveReply'); + +test('parseEditOptionId parses a bare field-only id', () => { + const parsed = parseEditOptionId('edit:img_1:heading'); + assert.deepEqual(parsed, { imageId: 'img_1', fieldName: 'heading' }); +}); + +test('parseEditOptionId returns null for an id with no edit: prefix', () => { + assert.equal(parseEditOptionId('something_else'), null); +}); + +test('parseEditOptionId returns null for an id missing the field separator', () => { + assert.equal(parseEditOptionId('edit:img_1'), null); +}); + +test('parseEditOptionId returns null for a non-string id', () => { + assert.equal(parseEditOptionId(undefined), null); +}); + +test('messageTextForInteractiveReply builds a change-field message for a bare field id', () => { + const text = messageTextForInteractiveReply({ id: 'edit:img_1:heading', title: 'Change heading' }); + assert.equal(text, 'I\'d like to change "heading" on image img_1.'); +}); + +test('messageTextForInteractiveReply falls back to the title when the id is unparseable', () => { + const text = messageTextForInteractiveReply({ id: 'not-an-edit-id', title: 'Some Title' }); + assert.equal(text, 'Some Title'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test src/interactiveReply.test.js` +Expected: FAIL — `Cannot find module './interactiveReply'` + +- [ ] **Step 3: Create the module by moving the existing logic out of app.js** + +Create `src/interactiveReply.js`: + +```js +// Edit option button/list-row ids are `edit:${imageId}:${fieldName}` (see +// expressApi.buildEditOptions). Parse that back out so a tap can tell GPT exactly +// which image and field the user picked, instead of only the truncated button title. +function parseEditOptionId(id) { + if (typeof id !== 'string' || !id.startsWith('edit:')) return null; + const rest = id.slice('edit:'.length); + const separatorIndex = rest.indexOf(':'); + if (separatorIndex === -1) return null; + return { imageId: rest.slice(0, separatorIndex), fieldName: rest.slice(separatorIndex + 1) }; +} + +function messageTextForInteractiveReply(reply) { + const parsed = parseEditOptionId(reply.id); + if (!parsed) return reply.title; + return `I'd like to change "${parsed.fieldName}" on image ${parsed.imageId}.`; +} + +module.exports = { parseEditOptionId, messageTextForInteractiveReply }; +``` + +Now remove the two functions from `app.js:95-110` (the block starting with the `// Edit option button/list-row ids are...` comment and ending after the `messageTextForInteractiveReply` function), and instead require them at the top. Change `src/app.js:3-9` from: + +```js +const { getTrackedImages } = require('./imageStore'); +const { + actionListCampaignGraphics, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, +} = require('./actions'); +``` + +to: + +```js +const { getTrackedImages } = require('./imageStore'); +const { + actionListCampaignGraphics, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, +} = require('./actions'); +const { parseEditOptionId, messageTextForInteractiveReply } = require('./interactiveReply'); +``` + +`app.js:252-253` (the webhook loop) is unchanged — it already just calls `messageTextForInteractiveReply(interactiveReply)`, which now resolves to the imported function instead of a local one. `parseEditOptionId` is imported for use in Task 4's wiring even though nothing in `app.js` calls it directly yet after this task. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --test src/interactiveReply.test.js` +Expected: PASS (6 tests) + +- [ ] **Step 5: Run the full suite to confirm nothing else broke** + +Run: `node --test src/` +Expected: same pass/fail counts as before this task (32 pass, 2 pre-existing unrelated failures in `actions.test.js` — see Note below) + +> **Note:** `src/actions.test.js` has 2 pre-existing failing tests (`actionCheckAllowedEdits lists the tagged elements...` and `...shows the latest edited value...`) unrelated to this plan — they assert `reply` is a string, but `actionCheckAllowedEdits` has returned an object since an earlier commit. Do not fix them as part of this plan; just confirm the count doesn't change. + +- [ ] **Step 6: Commit** + +```bash +git add src/interactiveReply.js src/interactiveReply.test.js src/app.js +git commit -m "Extract interactive-reply id parsing into a testable module" +``` + +--- + +### Task 2: Support fully-specified multi-field edits in interactive reply ids + +**Files:** +- Modify: `src/interactiveReply.js` +- Modify: `src/interactiveReply.test.js` + +**Interfaces:** +- Consumes: nothing new. +- Produces: `buildValueEditId(imageId: string, edits: object) => string`, `parseEditOptionId` now also returns `{ imageId: string, edits: object }` (no `fieldName`) when the id encodes a full edits object, `messageTextForInteractiveReply` now also handles that shape. Task 3 (`actionSelectTvModel`) calls `buildValueEditId`. Task 4 (`app.js` webhook wiring) relies on `parseEditOptionId`/`messageTextForInteractiveReply` handling both shapes transparently — no change needed in `app.js` for this task. + +- [ ] **Step 1: Write the failing tests** + +Add to `src/interactiveReply.test.js`: + +```js +const { buildValueEditId, parseEditOptionId, messageTextForInteractiveReply } = require('./interactiveReply'); + +test('buildValueEditId round-trips through parseEditOptionId', () => { + const edits = { productImage: 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000', oldPrice: 33999, price: 27199 }; + const id = buildValueEditId('img_1', edits); + const parsed = parseEditOptionId(id); + assert.deepEqual(parsed, { imageId: 'img_1', edits }); +}); + +test('parseEditOptionId still parses a bare field-only id after the value-edit change', () => { + const parsed = parseEditOptionId('edit:img_1:heading'); + assert.deepEqual(parsed, { imageId: 'img_1', fieldName: 'heading' }); +}); + +test('messageTextForInteractiveReply lists every field/value for a fully-specified edit id', () => { + const id = buildValueEditId('img_1', { productImage: 'https://example.com/tv.png', oldPrice: 33999, price: 27199 }); + const text = messageTextForInteractiveReply({ id, title: 'Sony Bravia K-75' }); + assert.equal( + text, + 'I\'d like to change "productImage" to "https://example.com/tv.png", "oldPrice" to "33999", "price" to "27199" on image img_1.' + ); +}); +``` + +(Update the existing `require` at the top of the test file to include `buildValueEditId` in the destructure — one `require` line covering all four exports is fine.) + +- [ ] **Step 2: Run tests to verify the new ones fail** + +Run: `node --test src/interactiveReply.test.js` +Expected: FAIL — `buildValueEditId is not a function` (and the round-trip/message tests fail too) + +- [ ] **Step 3: Implement the extension** + +Replace the contents of `src/interactiveReply.js` with: + +```js +const EDIT_ID_PREFIX = 'edit:'; + +// Edit option button/list-row ids are `edit:${imageId}:${fieldName}` (bare field — +// see expressApi.buildEditOptions) or `edit:${imageId}:${encodeURIComponent(JSON.stringify(edits))}` +// (fully-specified — see actions.buildTvModelOptions) — parse either shape back out so +// a tap can tell GPT exactly what to do instead of only the truncated button title. +function buildValueEditId(imageId, edits) { + return `${EDIT_ID_PREFIX}${imageId}:${encodeURIComponent(JSON.stringify(edits))}`; +} + +function parseEditOptionId(id) { + if (typeof id !== 'string' || !id.startsWith(EDIT_ID_PREFIX)) return null; + const rest = id.slice(EDIT_ID_PREFIX.length); + const separatorIndex = rest.indexOf(':'); + if (separatorIndex === -1) return null; + const imageId = rest.slice(0, separatorIndex); + const remainder = rest.slice(separatorIndex + 1); + + try { + const decoded = JSON.parse(decodeURIComponent(remainder)); + if (decoded && typeof decoded === 'object' && !Array.isArray(decoded)) { + return { imageId, edits: decoded }; + } + } catch { + // Not JSON — remainder is a bare field name, handled below. + } + + return { imageId, fieldName: remainder }; +} + +function describeEdits(edits) { + return Object.entries(edits) + .map(([key, value]) => `"${key}" to "${value}"`) + .join(', '); +} + +function messageTextForInteractiveReply(reply) { + const parsed = parseEditOptionId(reply.id); + if (!parsed) return reply.title; + if (parsed.edits) { + return `I'd like to change ${describeEdits(parsed.edits)} on image ${parsed.imageId}.`; + } + return `I'd like to change "${parsed.fieldName}" on image ${parsed.imageId}.`; +} + +module.exports = { buildValueEditId, parseEditOptionId, messageTextForInteractiveReply }; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test src/interactiveReply.test.js` +Expected: PASS (9 tests) + +- [ ] **Step 5: Run the full suite** + +Run: `node --test src/` +Expected: same as Task 1's Step 5 (no new failures) + +- [ ] **Step 6: Commit** + +```bash +git add src/interactiveReply.js src/interactiveReply.test.js +git commit -m "Support fully-specified multi-field edits in interactive reply ids" +``` + +--- + +### Task 3: Add the TV model options handler + +**Files:** +- Modify: `src/actions.js` +- Modify: `src/actions.test.js` + +**Interfaces:** +- Consumes: `buildValueEditId(imageId, edits)` from `./interactiveReply` (Task 2). +- Produces: `actionSelectTvModel(imageId) => { type: 'edit_options', bodyText: string, options: Array<{ id: string, title: string }> }` (synchronous — no Express API call needed to build the fixed 3-option list). Task 4 (`app.js` webhook wiring) calls this and passes the result straight into the existing `sendEditOptions(phoneNumber, result)` — same shape `actionCheckAllowedEdits` already produces. + +- [ ] **Step 1: Write the failing tests** + +Add to `src/actions.test.js` (add `actionSelectTvModel` to the existing `require('./actions')` destructure, and add `parseEditOptionId` from `./interactiveReply` for assertions): + +```js +const { actionCheckAllowedEdits, actionEditGraphic, actionSelectTvModel } = require('./actions'); +const { parseEditOptionId } = require('./interactiveReply'); + +test('actionSelectTvModel returns the 3 fixed TV model options with the question body text', () => { + const result = actionSelectTvModel('img_1'); + + assert.equal(result.type, 'edit_options'); + assert.equal(result.bodyText, 'Which model would you like to use?'); + assert.equal(result.options.length, 3); + assert.deepEqual( + result.options.map((option) => option.title), + ['Sony Bravia K-75', 'LG UA82 AI', 'Samsung UA4'] + ); +}); + +test('actionSelectTvModel encodes the same fixed productImage/oldPrice/price edits into every option id', () => { + const result = actionSelectTvModel('img_1'); + + for (const option of result.options) { + const parsed = parseEditOptionId(option.id); + assert.deepEqual(parsed, { + imageId: 'img_1', + edits: { + productImage: 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000', + oldPrice: 33999, + price: 27199, + }, + }); + } +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `node --test src/actions.test.js` +Expected: FAIL — `actionSelectTvModel is not a function` + +- [ ] **Step 3: Implement `actionSelectTvModel`** + +In `src/actions.js`, add near the top (after the existing `require`s at `src/actions.js:1-2`): + +```js +const { buildValueEditId } = require('./interactiveReply'); + +const TV_PLACEHOLDER_IMAGE_URL = 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000'; +const TV_MODEL_TITLES = ['Sony Bravia K-75', 'LG UA82 AI', 'Samsung UA4']; +const TV_MODEL_EDITS = { productImage: TV_PLACEHOLDER_IMAGE_URL, oldPrice: 33999, price: 27199 }; +``` + +Then add the function itself (near `actionCheckAllowedEdits`, e.g. directly after it): + +```js +function actionSelectTvModel(imageId) { + return { + type: 'edit_options', + bodyText: 'Which model would you like to use?', + options: TV_MODEL_TITLES.map((title) => ({ + id: buildValueEditId(imageId, TV_MODEL_EDITS), + title, + })), + }; +} +``` + +Finally, add `actionSelectTvModel` to `module.exports` at the bottom of `src/actions.js`: + +```js +module.exports = { + actionListCampaignGraphics, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, + actionSelectTvModel, +}; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test src/actions.test.js` +Expected: PASS for the 2 new tests (the 2 pre-existing unrelated failures from Task 1's Note still fail — that's expected, don't touch them) + +- [ ] **Step 5: Run the full suite** + +Run: `node --test src/` +Expected: 34 pass, 2 pre-existing unrelated failures (same 2 as before — count of passing tests increases by 2 vs. Task 2's baseline) + +- [ ] **Step 6: Commit** + +```bash +git add src/actions.js src/actions.test.js +git commit -m "Add actionSelectTvModel handler for the TV product-swap quick replies" +``` + +--- + +### Task 4: Wire the `select_tv_model` GPT tool into the webhook + +**Files:** +- Modify: `src/app.js` + +**Interfaces:** +- Consumes: `actionSelectTvModel(imageId)` from `./actions` (Task 3). +- Produces: nothing new for later tasks — this is the last piece of wiring. + +- [ ] **Step 1: Import `actionSelectTvModel`** + +In `src/app.js:4-9`, change: + +```js +const { + actionListCampaignGraphics, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, +} = require('./actions'); +``` + +to: + +```js +const { + actionListCampaignGraphics, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, + actionSelectTvModel, +} = require('./actions'); +``` + +- [ ] **Step 2: Add the `select_tv_model` tool definition** + +In `src/app.js`, in the `tools` array (currently `src/app.js:114-184`), add a new entry. Insert it right after the `check_allowed_edits` tool definition (ends at `src/app.js:151`) and before `edit_graphic`: + +```js + { + type: 'function', + function: { + name: 'select_tv_model', + description: + 'Use when the user asks to change or set the product in a graphic to a TV, without specifying which model. Do not use this for edits to text fields or other product types — use edit_graphic for those.', + parameters: { + type: 'object', + properties: { + image_id: { type: 'string', description: 'The id of the image to edit, from the tracked images list' }, + }, + required: ['image_id'], + }, + }, + }, +``` + +- [ ] **Step 3: Handle the tool call in the webhook switch statement** + +In `src/app.js`, the `switch (action)` block (currently `src/app.js:272-307`) has a `case 'check_allowed_edits':` block (`src/app.js:282-293`) that shows the pattern to follow — sending a placeholder text, calling the action, then rendering options via `sendEditOptions` and setting `skipSend = true`. Add a new case right after it: + +```js + case 'select_tv_model': { + const result = actionSelectTvModel(args.image_id); + await sendEditOptions(phoneNumber, result); + replyText = result.bodyText; + skipSend = true; + break; + } +``` + +(No "⏳ ..." placeholder text before this one — unlike `check_allowed_edits`/`edit_graphic`, it's synchronous and doesn't call the Express API, so there's nothing to wait on.) + +- [ ] **Step 4: Run the full test suite** + +Run: `node --test src/` +Expected: same pass/fail counts as Task 3's Step 5 (this task only touches `app.js`, which has no automated tests — see Global Constraints) + +- [ ] **Step 5: Manual verification via a local script** + +`app.js` can't be imported in a test, so verify the new wiring by exercising the pieces it composes directly in a scratch script. Run this with `node`: + +```js +const { actionSelectTvModel, actionEditGraphic } = require('./src/actions'); +const { parseEditOptionId, messageTextForInteractiveReply } = require('./src/interactiveReply'); + +// 1. Simulate GPT calling select_tv_model('img_1') after "change product to tv": +const result = actionSelectTvModel('img_1'); +console.log('Options shown to user:', result.options); + +// 2. Simulate the user tapping the first button — this is what the webhook receives: +const tappedReply = { id: result.options[0].id, title: result.options[0].title }; + +// 3. Simulate app.js turning that tap into a synthetic message for GPT: +console.log('Synthetic message sent to GPT:', messageTextForInteractiveReply(tappedReply)); + +// 4. Confirm parseEditOptionId recovers the exact edits object actionEditGraphic will receive: +console.log('Parsed edits:', parseEditOptionId(tappedReply.id)); +``` + +Expected output: +- `Options shown to user` has 3 entries titled `Sony Bravia K-75`, `LG UA82 AI`, `Samsung UA4`. +- `Synthetic message sent to GPT` reads: `I'd like to change "productImage" to "https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000", "oldPrice" to "33999", "price" to "27199" on image img_1.` +- `Parsed edits` is `{ imageId: 'img_1', edits: { productImage: '...', oldPrice: 33999, price: 27199 } }`. + +This confirms the full chain (options → tap → synthetic GPT message → recovered edits) works end to end without needing a live WhatsApp/OpenAI/Express connection. The remaining link — GPT actually calling `edit_graphic` with this synthetic message, and `actionEditGraphic` calling the real Express API — is already covered by the existing `edit_graphic` system-prompt rule and `actions.test.js` coverage; it doesn't need TV-specific testing since nothing about it is TV-specific. + +- [ ] **Step 6: Commit** + +```bash +git add src/app.js +git commit -m "Wire select_tv_model GPT tool into the webhook handler" +``` + +--- + +## Post-plan check + +After Task 4, run `node --test src/` one final time and confirm: the only failures are the 2 pre-existing ones already present before this plan started (`actionCheckAllowedEdits lists the tagged elements for a known image` and `actionCheckAllowedEdits shows the latest edited value instead of the stale original document value`). If any other test fails, stop and investigate before considering this plan done. diff --git a/docs/superpowers/specs/2026-07-22-tv-product-swap-design.md b/docs/superpowers/specs/2026-07-22-tv-product-swap-design.md new file mode 100644 index 0000000000..7ecbc2fc86 --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-tv-product-swap-design.md @@ -0,0 +1,75 @@ +# Design: "Change product to TV" quick-reply flow + +## Goal + +Support this WhatsApp conversation flow: + +1. User asks to change the product in a graphic to a TV (e.g. "change product to tv"), without naming a specific model. +2. System asks "Which model would you like to use?" with 3 tappable quick-reply buttons: Sony Bravia K-75 / LG UA82 AI / Samsung UA4. +3. User taps one. +4. System immediately calls the Adobe Express API to apply all of: `productImage` → the TV's image, `oldPrice` → 33999, `price` → 27199. No further clarifying question — the tap alone is enough to act. +5. System sends the updated image, same as any other edit. + +Scope is TV-only for now: one hardcoded trigger, one hardcoded list of 3 models, and (until real per-model assets exist) the same placeholder image URL and the same fixed `oldPrice`/`price` for all three buttons. Not designed as a general "product category" system — if a second category is needed later, this gets generalized then. + +## New GPT tool + +Added to the existing `tools` array in `app.js`, following the same shape as `check_allowed_edits`/`edit_graphic`: + +```js +select_tv_model({ image_id }) +``` + +Description: "Use when the user asks to change/set the product in a graphic to a TV, without specifying which model. Do not use this for edits to text fields — use edit_graphic for those." `image_id` is picked from the tracked-images list exactly like the other tools. + +## Action handler + +New `actionSelectTvModel(phoneNumber, imageId)` in `actions.js`, returning the same `{ type: 'edit_options', bodyText, options }` shape `actionCheckAllowedEdits` already returns, so it reuses `sendEditOptions` unchanged (3 options → one native button message, no "More edits" chunking). + +```js +const TV_MODELS = [ + { title: 'Sony Bravia K-75' }, + { title: 'LG UA82 AI' }, + { title: 'Samsung UA4' }, +]; +const TV_PLACEHOLDER_IMAGE_URL = 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000'; +const TV_EDITS = { productImage: TV_PLACEHOLDER_IMAGE_URL, oldPrice: 33999, price: 27199 }; +``` + +`bodyText`: `"Which model would you like to use?"`. Each option's `id` encodes the *full* edits object (not just a field name), since the tap must fully specify the change — see below. + +No `findTrackedImage`/Express lookup is needed at this step (unlike `check_allowed_edits`, which reads the real tagged document to build its option list); the 3 buttons are fixed regardless of the document. Validation still happens later, for free, in `actionEditGraphic`. + +## Button `id` encoding (extends the existing mechanism) + +Today, edit-option button ids are `edit::` (bare field, no value — tapping always leads to a clarifying question, per `app.js` `parseEditOptionId`/`messageTextForInteractiveReply`). + +The TV buttons need to carry a *value* too, and here it's actually multiple field/value pairs at once. A naive `field=value` suffix breaks because the image URL itself contains a literal `=` (`?wid=1000`), and there's more than one field. Instead, the remainder after `edit::` becomes a JSON-encoded edits object when a value is already known: + +``` +edit:: +``` + +`parseEditOptionId` is extended: after splitting off `imageId`, try `JSON.parse(decodeURIComponent(remainder))`. If it parses to a plain object, treat this as a **fully-specified edit** (`{ imageId, edits }`). If parsing throws, fall back to the existing behavior — remainder is a bare field name (`{ imageId, fieldName }`). + +`messageTextForInteractiveReply` is extended to match: +- Fully-specified (`edits` present): build a synthetic user message listing every field/value, e.g. `I'd like to change "productImage" to "https://...", "oldPrice" to "33999", "price" to "27199" on image img_1.` GPT already has a system-prompt rule that a fully-specified request should go straight to `edit_graphic` — no new prompt change needed here. +- Bare field (existing case): unchanged — asks GPT to prompt for the missing value. + +This keeps one general mechanism for "a button tap that already knows its value(s)" rather than adding TV-specific parsing. + +## Validation / error handling + +Unchanged and reused: when `edit_graphic` runs, `actionEditGraphic` already checks every requested key against the real tagged document's element names and rejects unknown ones with the existing friendly message. If `productImage`/`oldPrice`/`price` aren't real tags on a given document, the user gets that existing rejection message — no new error handling to write. + +## Testing + +- Unit test for the extended `parseEditOptionId`/`messageTextForInteractiveReply`: bare-field id still parses as before; a JSON-encoded multi-field id parses to `{ imageId, edits }` and produces a message listing all fields/values. +- Unit test for `actionSelectTvModel`: returns 3 options, each titled with a model name, each `id` decodable back to `{ productImage, oldPrice, price }` with the expected values. +- Manual/simulated webhook check: "change product to tv" → 3 buttons in one message → tap one → `edit_graphic` called with all 3 fields → thumbnail sent. + +## Out of scope + +- Per-model images or pricing (all 3 buttons currently share the same values). +- Any product category other than TV. +- A data-driven/config-file catalog of product categories — revisit if a second category is actually needed. diff --git a/src/actions.js b/src/actions.js index 4fdbfb0db5..42ffa98ef4 100644 --- a/src/actions.js +++ b/src/actions.js @@ -2,6 +2,11 @@ const fs = require('node:fs'); const path = require('node:path'); const { getTrackedImages, findTrackedImage, recordEdits, createDesign } = require('./imageStore'); const expressApi = require('./express/expressApi'); +const { buildValueEditId } = require('./interactiveReply'); + +const TV_PLACEHOLDER_IMAGE_URL = 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000'; +const TV_MODEL_TITLES = ['Sony Bravia K-75', 'LG UA82 AI', 'Samsung UA4']; +const TV_MODEL_EDITS = { productImage: TV_PLACEHOLDER_IMAGE_URL, oldPrice: 33999, price: 27199 }; function loadOnamDesign() { const filePath = process.env.ONAM_DESIGN_FILE || path.join(__dirname, '..', 'data', 'onam-design.json'); @@ -148,7 +153,7 @@ async function actionCheckAllowedEdits(phoneNumber, imageId) { return { type: 'edit_options', bodyText: 'What would you like to change?', - options: expressApi.buildEditOptions(elements), + options: expressApi.buildEditOptions(elements, imageId), historyText: expressApi.formatAllowedEdits(image.name, elements), }; } @@ -160,7 +165,7 @@ async function actionCheckAllowedEdits(phoneNumber, imageId) { return { type: 'edit_options', bodyText: 'What would you like to change?', - options: expressApi.buildEditOptions(elementsWithCurrentEdits), + options: expressApi.buildEditOptions(elementsWithCurrentEdits, imageId), historyText: expressApi.formatAllowedEdits(image.name, elementsWithCurrentEdits), }; } catch (err) { @@ -169,6 +174,17 @@ async function actionCheckAllowedEdits(phoneNumber, imageId) { } } +function actionSelectTvModel(imageId) { + return { + type: 'edit_options', + bodyText: 'Which model would you like to use?', + options: TV_MODEL_TITLES.map((title) => ({ + id: buildValueEditId(imageId, TV_MODEL_EDITS), + title, + })), + }; +} + // Routes to the right edit path based on where the design came from: // source: 'local' → canned-image design (Flow 2.2), no Express calls // source: 'express' → real Adobe Express-backed catalog design (Flow 2.1) @@ -255,4 +271,5 @@ module.exports = { actionCheckAllowedEdits, actionEditGraphic, actionGenerateBulkGraphics, + actionSelectTvModel, }; diff --git a/src/actions.test.js b/src/actions.test.js index 1b16b26ced..43e481e204 100644 --- a/src/actions.test.js +++ b/src/actions.test.js @@ -3,9 +3,10 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); -const { actionCheckAllowedEdits, actionEditGraphic } = require('./actions'); +const { actionCheckAllowedEdits, actionEditGraphic, actionSelectTvModel } = require('./actions'); const expressApi = require('./express/expressApi'); const { findTrackedImage, recordEdits } = require('./imageStore'); +const { parseEditOptionId } = require('./interactiveReply'); function writeFixtureCatalog(entries) { const fixturePath = path.join(os.tmpdir(), `express-templates-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); @@ -147,3 +148,31 @@ test('actionEditGraphic tells the user delivery failed but keeps the recorded ed const image = findTrackedImage('phone-7', 'img_1'); assert.deepEqual(image.currentEdits, { cta: '20% off' }); }); + +test('actionSelectTvModel returns the 3 fixed TV model options with the question body text', () => { + const result = actionSelectTvModel('img_1'); + + assert.equal(result.type, 'edit_options'); + assert.equal(result.bodyText, 'Which model would you like to use?'); + assert.equal(result.options.length, 3); + assert.deepEqual( + result.options.map((option) => option.title), + ['Sony Bravia K-75', 'LG UA82 AI', 'Samsung UA4'] + ); +}); + +test('actionSelectTvModel encodes the same fixed productImage/oldPrice/price edits into every option id', () => { + const result = actionSelectTvModel('img_1'); + + for (const option of result.options) { + const parsed = parseEditOptionId(option.id); + assert.deepEqual(parsed, { + imageId: 'img_1', + edits: { + productImage: 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000', + oldPrice: 33999, + price: 27199, + }, + }); + } +}); diff --git a/src/app.js b/src/app.js index fe965dcff9..276d602d3d 100644 --- a/src/app.js +++ b/src/app.js @@ -7,7 +7,9 @@ const { actionCheckAllowedEdits, actionEditGraphic, actionGenerateBulkGraphics, + actionSelectTvModel, } = require('./actions'); +const { parseEditOptionId, messageTextForInteractiveReply } = require('./interactiveReply'); const app = express(); app.use(express.json()); @@ -154,6 +156,21 @@ const tools = [ }, }, }, + { + type: 'function', + function: { + name: 'select_tv_model', + description: + 'Use when the user asks to change or set the product in a graphic to a TV, without specifying which model. Do not use this for edits to text fields or other product types — use edit_graphic for those.', + parameters: { + type: 'object', + properties: { + image_id: { type: 'string', description: 'The id of the image to edit, from the tracked images list' }, + }, + required: ['image_id'], + }, + }, + }, { type: 'function', function: { @@ -202,6 +219,7 @@ async function decideAction(phoneNumber, userMessage) { Analyze the user's message and conversation history, then call the appropriate tool. Always call exactly one tool — never reply with plain text. If the request is ambiguous or missing details, use ask_for_more_information. +If the user says which field they want to change but hasn't given the new value yet, call ask_for_more_information to ask what to change it to. If a later message in the conversation then supplies that value, call edit_graphic with the field and value instead of asking again. If the user wants a brand-new creative for an occasion that has no existing template (e.g. Onam, Pongal), use create_design. Choosing between edit_graphic and check_allowed_edits: if the user's message already contains a concrete change and its value (e.g. "make the background marigold", "add my address MG Road Kochi"), call edit_graphic with all of those changes in the edits object. Only call check_allowed_edits when the user asks what can be changed or wants the list of options WITHOUT giving a specific value. When editing, prefer these field names when they apply: headline, background, address, offer. @@ -257,7 +275,7 @@ app.post('/', async (req, res) => { if (message.image) console.log('[webhook] message.image:', JSON.stringify(message.image)); const interactiveReply = message?.interactive?.button_reply || message?.interactive?.list_reply; - const userText = message?.text?.body || interactiveReply?.title; + const userText = message?.text?.body || (interactiveReply && messageTextForInteractiveReply(interactiveReply)); if (!userText) continue; const phoneNumber = message.from; @@ -305,6 +323,14 @@ app.post('/', async (req, res) => { break; } + case 'select_tv_model': { + const result = actionSelectTvModel(args.image_id); + await sendEditOptions(phoneNumber, result); + replyText = result.bodyText; + skipSend = true; + break; + } + case 'edit_graphic': await sendText(phoneNumber, '⏳ Applying edits to your graphic...'); replyText = await actionEditGraphic(phoneNumber, args.image_id, args.edits, { sendImage }); diff --git a/src/express/expressApi.js b/src/express/expressApi.js index cb7ff9a857..9cc154df9c 100644 --- a/src/express/expressApi.js +++ b/src/express/expressApi.js @@ -99,9 +99,9 @@ function truncateTitle(title, maxLength = 20) { return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated; } -function buildEditOptions(elements) { +function buildEditOptions(elements, imageId) { return elements.map((element) => ({ - id: `edit:${element.name}`, + id: `edit:${imageId}:${element.name}`, title: truncateTitle(`Change ${humanizeFieldName(element.name)}`), })); } diff --git a/src/interactiveReply.js b/src/interactiveReply.js new file mode 100644 index 0000000000..d7aa55a63f --- /dev/null +++ b/src/interactiveReply.js @@ -0,0 +1,46 @@ +const EDIT_ID_PREFIX = 'edit:'; + +// Edit option button/list-row ids are `edit:${imageId}:${fieldName}` (bare field — +// see expressApi.buildEditOptions) or `edit:${imageId}:${encodeURIComponent(JSON.stringify(edits))}` +// (fully-specified — see actions.actionSelectTvModel) — parse either shape back out so +// a tap can tell GPT exactly what to do instead of only the truncated button title. +function buildValueEditId(imageId, edits) { + return `${EDIT_ID_PREFIX}${imageId}:${encodeURIComponent(JSON.stringify(edits))}`; +} + +function parseEditOptionId(id) { + if (typeof id !== 'string' || !id.startsWith(EDIT_ID_PREFIX)) return null; + const rest = id.slice(EDIT_ID_PREFIX.length); + const separatorIndex = rest.indexOf(':'); + if (separatorIndex === -1) return null; + const imageId = rest.slice(0, separatorIndex); + const remainder = rest.slice(separatorIndex + 1); + + try { + const decoded = JSON.parse(decodeURIComponent(remainder)); + if (decoded && typeof decoded === 'object' && !Array.isArray(decoded)) { + return { imageId, edits: decoded }; + } + } catch { + // Not JSON — remainder is a bare field name, handled below. + } + + return { imageId, fieldName: remainder }; +} + +function describeEdits(edits) { + return Object.entries(edits) + .map(([key, value]) => `"${key}" to "${value}"`) + .join(', '); +} + +function messageTextForInteractiveReply(reply) { + const parsed = parseEditOptionId(reply.id); + if (!parsed) return reply.title; + if (parsed.edits) { + return `I'd like to change ${describeEdits(parsed.edits)} on image ${parsed.imageId}.`; + } + return `I'd like to change "${parsed.fieldName}" on image ${parsed.imageId}.`; +} + +module.exports = { buildValueEditId, parseEditOptionId, messageTextForInteractiveReply }; diff --git a/src/interactiveReply.test.js b/src/interactiveReply.test.js new file mode 100644 index 0000000000..04a62c7e6b --- /dev/null +++ b/src/interactiveReply.test.js @@ -0,0 +1,51 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { buildValueEditId, parseEditOptionId, messageTextForInteractiveReply } = require('./interactiveReply'); + +test('parseEditOptionId parses a bare field-only id', () => { + const parsed = parseEditOptionId('edit:img_1:heading'); + assert.deepEqual(parsed, { imageId: 'img_1', fieldName: 'heading' }); +}); + +test('parseEditOptionId returns null for an id with no edit: prefix', () => { + assert.equal(parseEditOptionId('something_else'), null); +}); + +test('parseEditOptionId returns null for an id missing the field separator', () => { + assert.equal(parseEditOptionId('edit:img_1'), null); +}); + +test('parseEditOptionId returns null for a non-string id', () => { + assert.equal(parseEditOptionId(undefined), null); +}); + +test('messageTextForInteractiveReply builds a change-field message for a bare field id', () => { + const text = messageTextForInteractiveReply({ id: 'edit:img_1:heading', title: 'Change heading' }); + assert.equal(text, 'I\'d like to change "heading" on image img_1.'); +}); + +test('messageTextForInteractiveReply falls back to the title when the id is unparseable', () => { + const text = messageTextForInteractiveReply({ id: 'not-an-edit-id', title: 'Some Title' }); + assert.equal(text, 'Some Title'); +}); + +test('buildValueEditId round-trips through parseEditOptionId', () => { + const edits = { productImage: 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000', oldPrice: 33999, price: 27199 }; + const id = buildValueEditId('img_1', edits); + const parsed = parseEditOptionId(id); + assert.deepEqual(parsed, { imageId: 'img_1', edits }); +}); + +test('parseEditOptionId still parses a bare field-only id after the value-edit change', () => { + const parsed = parseEditOptionId('edit:img_1:heading'); + assert.deepEqual(parsed, { imageId: 'img_1', fieldName: 'heading' }); +}); + +test('messageTextForInteractiveReply lists every field/value for a fully-specified edit id', () => { + const id = buildValueEditId('img_1', { productImage: 'https://example.com/tv.png', oldPrice: 33999, price: 27199 }); + const text = messageTextForInteractiveReply({ id, title: 'Sony Bravia K-75' }); + assert.equal( + text, + 'I\'d like to change "productImage" to "https://example.com/tv.png", "oldPrice" to "33999", "price" to "27199" on image img_1.' + ); +}); From 2d3272ebf939f1e321df2347215fe0bc946550d5 Mon Sep 17 00:00:00 2001 From: varun kalra Date: Wed, 22 Jul 2026 18:06:08 +0530 Subject: [PATCH 25/35] refactor: split the two edit flows into self-contained modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two flows now live in their own files so changing one cannot affect the other; actions.js is a thin router that dispatches deterministically on image.source. No behavior change (all tests pass). - expressFlow.js: Flow 1 (source: 'express') — Adobe Express catalog designs (checkAllowedEdits, editGraphic, selectTvModel, discount cap) - localFlow.js: Flow 2 (source: 'local') — canned-image designs (createDesign, checkAllowedEdits, editGraphic, palette + Malayalam handling) - editOptions.js: shared pure presentation helpers (buildEditOptions, formatAllowedEdits), extracted so neither flow depends on the other; expressApi re-exports them for backward compatibility - actions.js: thin dispatcher only (resolve image → route by source) - tests mirror the modules: expressFlow.test.js + localFlow.test.js, with a small actions.test.js for router/dispatch behavior - docs/diagrams: mermaid flowcharts for both flows Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/diagrams/flow-express-catalog-edit.mmd | 26 ++ docs/diagrams/flow-local-create-design.mmd | 24 ++ src/actions.js | 281 ++---------------- src/actions.test.js | 174 ++--------- src/editOptions.js | 42 +++ src/express/expressApi.js | 40 +-- src/expressFlow.js | 125 ++++++++ src/expressFlow.test.js | 177 +++++++++++ src/localFlow.js | 150 ++++++++++ ...createDesign.test.js => localFlow.test.js} | 85 +++--- 10 files changed, 639 insertions(+), 485 deletions(-) create mode 100644 docs/diagrams/flow-express-catalog-edit.mmd create mode 100644 docs/diagrams/flow-local-create-design.mmd create mode 100644 src/editOptions.js create mode 100644 src/expressFlow.js create mode 100644 src/expressFlow.test.js create mode 100644 src/localFlow.js rename src/{createDesign.test.js => localFlow.test.js} (51%) diff --git a/docs/diagrams/flow-express-catalog-edit.mmd b/docs/diagrams/flow-express-catalog-edit.mmd new file mode 100644 index 0000000000..644ca94a8c --- /dev/null +++ b/docs/diagrams/flow-express-catalog-edit.mmd @@ -0,0 +1,26 @@ +%% Flow A — Rajesh: edit an existing catalog design (image.source = "express") +%% Backed by the real Adobe Express API, with interactive quick-reply buttons. +%% Render: https://mermaid.live or any Mermaid-capable viewer. +flowchart TD + R1["User: 'what can I change?'"] --> CAE["tool: check_allowed_edits"] + CAE --> CAEa["actionCheckAllowedEdits
image.source = express"] + CAEa --> EX1["expressApi.getTaggedDocument(docId)
collectTaggedElements"] + EX1 --> BTN["buildEditOptions
returns {edit_options, historyText}"] + BTN --> SEND1["sendEditOptions() → reply buttons
id = 'edit:img_1:heading'"] + + SEND1 -.->|"user taps a button"| TAP["webhook: messageTextForInteractiveReply()
parse id → I'd like to change heading on image img_1"] + TAP --> ASK["tool: ask_for_more_information
what would you like to change it to?"] + ASK -.->|"user replies with a value"| EG + + R2["User: 'change the offer to 7500'"] --> EG["tool: edit_graphic"] + EG --> RT{"actionEditGraphic
image.source?"} + RT -->|"express"| EXE["editExpressDesign"] + EXE --> V{"field is a tagged element?
discount ≤ MAX_DISCOUNT?"} + V -->|"ok"| GEN["expressApi.generateVariation
→ pollJobStatus → thumbnailUrl"] + GEN --> REC["recordEdits + sendImage(thumbnail)"] + V -->|"no"| REJ["reply: can't edit that field / discount capped"] + + R3["User: 'change the product to a TV'"] --> STV["tool: select_tv_model"] + STV --> STVa["actionSelectTvModel → 3 model buttons
id encodes full edits (buildValueEditId)"] + STVa -.->|"user taps e.g. LG UA82 AI"| TAP2["parse id → edits JSON → sentence → GPT"] + TAP2 --> EG diff --git a/docs/diagrams/flow-local-create-design.mmd b/docs/diagrams/flow-local-create-design.mmd new file mode 100644 index 0000000000..f24b40931f --- /dev/null +++ b/docs/diagrams/flow-local-create-design.mmd @@ -0,0 +1,24 @@ +%% Flow B — Anjali: create + edit a brand-new design (image.source = "local") +%% No Express calls — resolves to canned image URLs in data/onam-design.json. +%% Render: https://mermaid.live or any Mermaid-capable viewer. +flowchart TD + A1["User: 'create a new design for Onam,
LG washing machine + dishwasher, 20% off'"] --> CD["tool: create_design"] + CD --> CDa["actionCreateDesign
loadOnamDesign + imageStore.createDesign
(source = local, id = local_1)"] + CDa --> S1["sendImage(images.base)"] + + A2["User: 'add my store address Princess Street Kochi'"] --> EG2["tool: edit_graphic"] + A3["User: 'translate the banner to Malayalam'"] --> EG3["tool: edit_graphic"] + EG2 --> RT{"actionEditGraphic
image.source?"} + EG3 --> RT + RT -->|"local"| EL["editLocalDesign"] + + EL --> M{"Malayalam request?
(Malayalam script OR the word
'Malayalam' in ANY key/value)"} + M -->|"yes"| MAL["short-circuit:
appliedEdits = {language: 'Malayalam'}
skip locked/palette guards"] + M -->|"no"| CANON["canonicalizeEdits (aliases)"] + CANON --> GUARD{"unknown key? → 'locked by HQ'
background off-palette? → reject"} + GUARD -->|"ok"| RES["resolveLocalImage:
address/background set → images.final
else → images.base"] + MAL --> RESM["resolveLocalImage → images.malayalam"] + RES --> S2["recordEdits + sendImage(url)"] + RESM --> S2 + + RT -->|"express"| EXE["editExpressDesign
(Adobe Express API — see other diagram)"] diff --git a/src/actions.js b/src/actions.js index c79181a242..90662359b3 100644 --- a/src/actions.js +++ b/src/actions.js @@ -1,17 +1,13 @@ -const fs = require('node:fs'); -const path = require('node:path'); -const { getTrackedImages, findTrackedImage, recordEdits, createDesign } = require('./imageStore'); -const expressApi = require('./express/expressApi'); -const { buildValueEditId } = require('./interactiveReply'); +// Thin action layer for the GPT tools. The two edit flows live in their own +// self-contained modules and are dispatched deterministically by image.source: +// source: 'express' → expressFlow (real Adobe Express API — catalog designs) +// source: 'local' → localFlow (canned image URLs — runtime-created designs) +// Keeping this file free of flow-specific logic means changing one flow can never +// affect the other. -const TV_PLACEHOLDER_IMAGE_URL = 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000'; -const TV_MODEL_TITLES = ['Sony Bravia K-75', 'LG UA82 AI', 'Samsung UA4']; -const TV_MODEL_EDITS = { productImage: TV_PLACEHOLDER_IMAGE_URL, oldPrice: 33999, price: 27199 }; - -function loadOnamDesign() { - const filePath = process.env.ONAM_DESIGN_FILE || path.join(__dirname, '..', 'data', 'onam-design.json'); - return JSON.parse(fs.readFileSync(filePath, 'utf8')); -} +const { getTrackedImages, findTrackedImage } = require('./imageStore'); +const expressFlow = require('./expressFlow'); +const localFlow = require('./localFlow'); function formatUnknownImageMessage(phoneNumber) { const images = getTrackedImages(phoneNumber); @@ -25,263 +21,36 @@ async function actionListCampaignGraphics() { return 'Graphics in your current campaign:\n1. Croma Earbuds'; } -// Create a brand-new creative from a text description (Flow 2.2 — Onam). -// Unlike the catalog designs, this has no Adobe Express document; it is a -// `source: 'local'` design that resolves to canned images (see data/onam-design.json). -async function actionCreateDesign(phoneNumber, { occasion, products, offer } = {}, { sendImage }) { - const design = loadOnamDesign(); - const productList = Array.isArray(products) && products.length ? products.join(' + ') : (products || 'your products'); - const name = [occasion || 'Festive', productList, 'offer'].filter(Boolean).join(' '); - console.log('[action:create_design]', { phoneNumber, occasion, products, offer, image: design.images.base }); - createDesign(phoneNumber, { name, design }); - - try { - await sendImage(phoneNumber, design.images.base); - } catch (err) { - console.error('[actionCreateDesign] sendImage error', { message: err.message }); - return `I built your ${occasion || 'festive'} design, but couldn't send the image right now — try asking me to resend it.`; - } - - return `Here you go 🌼 Built with Croma's logo, approved festive colours and the ${productList} images. Want to tweak anything?`; -} - -function withCurrentEdits(elements, currentEdits) { - return elements.map((element) => - element.name in currentEdits ? { ...element, value: currentEdits[element.name] } : element - ); -} - -const MAX_DISCOUNT_PERCENT = 40; - -function isDiscountField(name) { - return /discount/i.test(name); -} - -function parsePercent(value) { - const match = String(value).match(/(\d+(?:\.\d+)?)/); - return match ? Number(match[1]) : null; -} - -// ── Local (canned-image) design helpers — Flow 2.2 ─────────────────────────── - -function normalizeKey(key) { - return String(key).toLowerCase().trim().replace(/\s+/g, '_'); -} - -// Map GPT's free-form edit keys onto the design's canonical slot names via -// aliases, so "background_color" / "colour" / "heading" all resolve correctly. -function canonicalizeEdits(editableSlots, edits) { - const canonical = {}; - const unknown = []; - for (const [rawKey, value] of Object.entries(edits || {})) { - const key = normalizeKey(rawKey); - const slot = editableSlots.find( - (s) => normalizeKey(s.name) === key || (s.aliases || []).some((a) => normalizeKey(a) === key) - ); - if (slot) canonical[slot.name] = value; - else unknown.push(rawKey); - } - return { canonical, unknown }; -} - -function isPaletteColor(design, value) { - const v = String(value).trim().toLowerCase(); - return (design.palette || []).some((c) => c.name.toLowerCase() === v || c.hex.toLowerCase() === v); -} - -function hasMalayalam(value) { - return /[ഀ-ൿ]/.test(String(value)); -} - -// Pick the canned image for the current accumulated edit state. -function isMalayalamEdit(currentEdits) { - return Object.entries(currentEdits).some(([key, value]) => - hasMalayalam(String(value)) || (key === 'language' && /malayalam/i.test(String(value))) - ); -} - -function resolveLocalImage(design, currentEdits) { - if (isMalayalamEdit(currentEdits)) return design.images.malayalam; - if (currentEdits.background || currentEdits.address) return design.images.final; - return design.images.base; -} - -function localEditElements(image) { - return image.design.slots.editable.map((slot) => ({ - name: slot.name, - type: slot.type || 'text', - value: image.currentEdits[slot.name] ?? '', - })); +// Flow 2 (local/canned) — create a brand-new design from a text description. +function actionCreateDesign(phoneNumber, args, ctx) { + return localFlow.createDesign(phoneNumber, args, ctx); } -async function editLocalDesign(phoneNumber, image, rawEdits, { sendImage }) { - const design = image.design; - const editableSlots = design.slots.editable; - - // Translation is special: the model may pass the Malayalam text (or the word - // "Malayalam") under any key. Detect it up front so a "translate to Malayalam" - // request always maps to the Malayalam creative, regardless of the edit key. - const rawText = Object.entries(rawEdits || {}).flat().map(String); - const wantsMalayalam = rawText.some(hasMalayalam) || rawText.some((s) => /malayalam/i.test(s)); - - // A translation request short-circuits everything: map straight to the - // Malayalam creative. Never treat it as a field edit or run it through the - // palette / locked-field guardrails (the model may put the Malayalam text on - // any key, including one that looks like "background"). - let appliedEdits; - if (wantsMalayalam) { - appliedEdits = { language: 'Malayalam' }; - } else { - const { canonical: edits, unknown } = canonicalizeEdits(editableSlots, rawEdits); - - if (unknown.length > 0) { - const editableNames = editableSlots.map((s) => s.name).join(', '); - return `I can't edit ${unknown.join(', ')} on "${image.name}" — those are locked by HQ. You can change: ${editableNames}.`; - } - - if ('background' in edits && !isPaletteColor(design, edits.background)) { - const options = design.palette.map((c) => c.name).join(' · '); - return `"${edits.background}" isn't in the approved palette 🙂 Here are the festive accents you can pick from: ${options}`; - } - - appliedEdits = edits; - } - - recordEdits(phoneNumber, image.id, appliedEdits); - const currentEdits = { ...image.currentEdits, ...appliedEdits }; - const imageUrl = resolveLocalImage(design, currentEdits); - console.log('[edit:local] resolved image', { imageId: image.id, currentEdits, imageUrl }); - - const summary = wantsMalayalam - ? '• translated to Malayalam' - : Object.entries(appliedEdits).map(([key, value]) => `• ${key}: ${value}`).join('\n'); - - try { - await sendImage(phoneNumber, imageUrl); - } catch (err) { - console.error('[editLocalDesign] sendImage error', { imageId: image.id, message: err.message }); - return `Updated "${image.name}", but I couldn't send the image right now — try asking me to resend it.`; - } - - return `Updated "${image.name}":\n${summary}`; +// Flow 1 (express/catalog) — offer the fixed TV models as quick replies. +function actionSelectTvModel(imageId) { + return expressFlow.selectTvModel(imageId); } +// Router: resolve the image, then hand off to the flow that owns it. async function actionCheckAllowedEdits(phoneNumber, imageId) { const image = findTrackedImage(phoneNumber, imageId); console.log('[action:check_allowed_edits]', { phoneNumber, imageId, source: image?.source ?? 'not_found' }); - if (!image) { - return formatUnknownImageMessage(phoneNumber); - } - - if (image.source === 'local') { - const elements = localEditElements(image); - return { - type: 'edit_options', - bodyText: 'What would you like to change?', - options: expressApi.buildEditOptions(elements, imageId), - historyText: expressApi.formatAllowedEdits(image.name, elements), - }; - } - - try { - const doc = await expressApi.getTaggedDocument(image.docId); - const elements = expressApi.collectTaggedElements(doc); - const elementsWithCurrentEdits = withCurrentEdits(elements, image.currentEdits); - return { - type: 'edit_options', - bodyText: 'What would you like to change?', - options: expressApi.buildEditOptions(elementsWithCurrentEdits, imageId), - historyText: expressApi.formatAllowedEdits(image.name, elementsWithCurrentEdits), - }; - } catch (err) { - console.error('[actionCheckAllowedEdits] Express API error', { docId: image.docId, message: err.message }); - return `Sorry, I couldn't check the allowed edits for "${image.name}" right now. Please try again in a moment.`; - } -} + if (!image) return formatUnknownImageMessage(phoneNumber); -function actionSelectTvModel(imageId) { - return { - type: 'edit_options', - bodyText: 'Which model would you like to use?', - options: TV_MODEL_TITLES.map((title) => ({ - id: buildValueEditId(imageId, TV_MODEL_EDITS), - title, - })), - }; + return image.source === 'local' + ? localFlow.checkAllowedEdits(image) + : expressFlow.checkAllowedEdits(image); } -// Routes to the right edit path based on where the design came from: -// source: 'local' → canned-image design (Flow 2.2), no Express calls -// source: 'express' → real Adobe Express-backed catalog design (Flow 2.1) +// Router: resolve the image, then hand off to the flow that owns it. async function actionEditGraphic(phoneNumber, imageId, edits, { sendImage }) { const image = findTrackedImage(phoneNumber, imageId); console.log('[action:edit_graphic]', { phoneNumber, imageId, source: image?.source ?? 'not_found', edits }); - if (!image) { - return formatUnknownImageMessage(phoneNumber); - } - - if (image.source === 'local') { - return editLocalDesign(phoneNumber, image, edits, { sendImage }); - } - return editExpressDesign(phoneNumber, image, edits, { sendImage }); -} - -async function editExpressDesign(phoneNumber, image, edits, { sendImage }) { - let elements; - try { - const doc = await expressApi.getTaggedDocument(image.docId); - elements = expressApi.collectTaggedElements(doc); - } catch (err) { - console.error('[editExpressDesign] Express API error', { docId: image.docId, message: err.message }); - return `Sorry, I couldn't reach Adobe Express to apply that edit. Please try again in a moment.`; - } - - const allowedNames = elements.map((element) => element.name); - const requestedKeys = Object.keys(edits || {}); - const disallowedKeys = requestedKeys.filter((key) => !allowedNames.includes(key)); - - if (disallowedKeys.length > 0) { - const elementsWithCurrentEdits = withCurrentEdits(elements, image.currentEdits); - return `I can't edit ${disallowedKeys.join(', ')} on "${image.name}". ${expressApi.formatAllowedEdits(image.name, elementsWithCurrentEdits)}`; - } - - const oversizedDiscountKeys = requestedKeys.filter((key) => { - if (!isDiscountField(key)) return false; - const percent = parsePercent(edits[key]); - return percent !== null && percent > MAX_DISCOUNT_PERCENT; - }); - - if (oversizedDiscountKeys.length > 0) { - return `The maximum discount I can apply on "${image.name}" is ${MAX_DISCOUNT_PERCENT}%. Try again with ${MAX_DISCOUNT_PERCENT}% or less.`; - } - - const mergedEdits = { ...image.currentEdits, ...edits }; - const pages = expressApi.pagesForEdits(elements, Object.keys(mergedEdits)); - const preferredDocumentName = expressApi.buildPreferredDocumentName(image.name); - - let thumbnailUrl; - try { - const { statusUrl } = await expressApi.generateVariation(image.docId, mergedEdits, pages, preferredDocumentName); - const result = await expressApi.pollJobStatus(statusUrl); - thumbnailUrl = result.document.thumbnailUrl; - console.log('[edit:express] resolved image', { imageId: image.id, docId: image.docId, thumbnailUrl }); - } catch (err) { - console.error('[editExpressDesign] generate/poll error', { docId: image.docId, message: err.message }); - return `Sorry, something went wrong generating your updated "${image.name}". Please try again.`; - } - - recordEdits(phoneNumber, image.id, edits); - - const summary = Object.entries(edits).map(([key, value]) => `• ${key}: ${value}`).join('\n'); - - try { - await sendImage(phoneNumber, thumbnailUrl); - } catch (err) { - console.error('[editExpressDesign] sendImage error', { docId: image.docId, message: err.message }); - return `Updated "${image.name}", but I couldn't send the image right now — try asking me to resend it.`; - } + if (!image) return formatUnknownImageMessage(phoneNumber); - return `Updated "${image.name}":\n${summary}`; + return image.source === 'local' + ? localFlow.editGraphic(phoneNumber, image, edits, { sendImage }) + : expressFlow.editGraphic(phoneNumber, image, edits, { sendImage }); } async function actionGenerateBulkGraphics(filename) { diff --git a/src/actions.test.js b/src/actions.test.js index 43e481e204..2f0603f92b 100644 --- a/src/actions.test.js +++ b/src/actions.test.js @@ -1,12 +1,12 @@ +// Router-level tests for actions.js — image resolution, not-found handling, and +// deterministic dispatch by image.source. Flow behavior itself is covered in +// expressFlow.test.js and localFlow.test.js. const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); -const { actionCheckAllowedEdits, actionEditGraphic, actionSelectTvModel } = require('./actions'); -const expressApi = require('./express/expressApi'); -const { findTrackedImage, recordEdits } = require('./imageStore'); -const { parseEditOptionId } = require('./interactiveReply'); +const { actionCreateDesign, actionCheckAllowedEdits, actionEditGraphic } = require('./actions'); function writeFixtureCatalog(entries) { const fixturePath = path.join(os.tmpdir(), `express-templates-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); @@ -14,165 +14,41 @@ function writeFixtureCatalog(entries) { process.env.EXPRESS_TEMPLATES_FILE = fixturePath; } -const SAMPLE_ELEMENTS_DOC = { - documentPages: [ - { - pageNumber: 1, - taggedElements: [ - { name: 'heading', type: 'text', value: 'The X-Phone Pro is here!' }, - { name: 'cta', type: 'text', value: 'Available at our store starting 15 Aug 20XX.' }, - ], - }, - ], -}; - -test('actionCheckAllowedEdits lists the tagged elements for a known image', async () => { - writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); - expressApi.getTaggedDocument = async (docId) => { - assert.equal(docId, 'urn:doc:1'); - return SAMPLE_ELEMENTS_DOC; +function writeOnamFixture() { + const onam = { + images: { base: 'https://cdn.test/b.png', final: 'https://cdn.test/f.png', malayalam: 'https://cdn.test/m.png' }, + palette: [{ name: 'Marigold', hex: '#F4A300' }], + slots: { editable: [{ name: 'address', type: 'text', aliases: [] }], locked: [] }, }; - - const reply = await actionCheckAllowedEdits('phone-1', 'img_1'); - - assert.match(reply.historyText, /Croma Earbuds/); - assert.match(reply.historyText, /heading: currently "The X-Phone Pro is here!"/); - assert.match(reply.historyText, /cta: currently/); -}); - -test('actionCheckAllowedEdits shows the latest edited value instead of the stale original document value', async () => { - writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); - expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; - recordEdits('phone-1b', 'img_1', { cta: '20% off' }); - - const reply = await actionCheckAllowedEdits('phone-1b', 'img_1'); - - assert.match(reply.historyText, /cta: currently "20% off"/); - assert.doesNotMatch(reply.historyText, /Available at our store starting 15 Aug 20XX\./); - assert.match(reply.historyText, /heading: currently "The X-Phone Pro is here!"/); -}); + const p = path.join(os.tmpdir(), `onam-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + fs.writeFileSync(p, JSON.stringify(onam)); + process.env.ONAM_DESIGN_FILE = p; +} test('actionCheckAllowedEdits reports unknown images without throwing', async () => { writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); - const reply = await actionCheckAllowedEdits('phone-2', 'img_nope'); + const reply = await actionCheckAllowedEdits('phone-r1', 'img_nope'); assert.match(reply, /couldn't find that image/); }); -test('actionCheckAllowedEdits returns a friendly message when the Express API call fails', async () => { - writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); - expressApi.getTaggedDocument = async () => { - throw new Error('getTaggedDocument failed 500: boom'); - }; - - const reply = await actionCheckAllowedEdits('phone-3', 'img_1'); - - assert.match(reply, /couldn't check the allowed edits/); -}); - -test('actionEditGraphic rejects edits outside the tagged elements and makes no generate call', async () => { - writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); - expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; - expressApi.generateVariation = async () => { - throw new Error('should not be called'); - }; - let sendImageCalled = false; - const sendImage = async () => { sendImageCalled = true; }; - - const reply = await actionEditGraphic('phone-4', 'img_1', { background_color: 'red' }, { sendImage }); - - assert.match(reply, /can't edit background_color/); - assert.equal(sendImageCalled, false); -}); - -test('actionEditGraphic applies an allowed edit end-to-end: generates, polls, sends the thumbnail, and records the edit', async () => { - writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); - expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; - expressApi.generateVariation = async (docId, tagMappings, pages, preferredDocumentName) => { - assert.equal(docId, 'urn:doc:1'); - assert.deepEqual(tagMappings, { cta: '20% off' }); - assert.equal(pages, '1'); - assert.match(preferredDocumentName, /^Croma Earbuds-edit-\d+$/); - return { jobId: 'job-1', statusUrl: 'https://express-api.adobe.io/status/job-1' }; - }; - expressApi.pollJobStatus = async (statusUrl) => { - assert.equal(statusUrl, 'https://express-api.adobe.io/status/job-1'); - return { status: 'succeeded', document: { thumbnailUrl: 'https://example.com/thumb.png' } }; - }; - - const sentCalls = []; - const sendImage = async (to, link) => { sentCalls.push({ to, link }); }; - - const reply = await actionEditGraphic('phone-5', 'img_1', { cta: '20% off' }, { sendImage }); - - assert.match(reply, /Updated "Croma Earbuds"/); - assert.equal(sentCalls.length, 1); - assert.equal(sentCalls[0].to, 'phone-5'); - assert.equal(sentCalls[0].link, 'https://example.com/thumb.png'); - - const image = findTrackedImage('phone-5', 'img_1'); - assert.deepEqual(image.currentEdits, { cta: '20% off' }); -}); - -test('actionEditGraphic returns a friendly message and does not record the edit when generation fails', async () => { - writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); - expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; - expressApi.generateVariation = async () => { throw new Error('generateVariation failed 500: boom'); }; - - let sendImageCalled = false; - const sendImage = async () => { sendImageCalled = true; }; - - const reply = await actionEditGraphic('phone-6', 'img_1', { cta: '20% off' }, { sendImage }); - - assert.match(reply, /something went wrong generating/); - assert.equal(sendImageCalled, false); - - const image = findTrackedImage('phone-6', 'img_1'); - assert.deepEqual(image.currentEdits, {}); -}); - -test('actionEditGraphic tells the user delivery failed but keeps the recorded edit when sendImage throws', async () => { +test('actionEditGraphic reports unknown images without throwing', async () => { writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); - expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; - expressApi.generateVariation = async () => ({ jobId: 'job-1', statusUrl: 'https://express-api.adobe.io/status/job-1' }); - expressApi.pollJobStatus = async () => ({ status: 'succeeded', document: { thumbnailUrl: 'https://example.com/thumb.png' } }); - const sendImage = async () => { throw new Error('WhatsApp could not fetch the link'); }; + const reply = await actionEditGraphic('phone-r2', 'img_nope', { x: 'y' }, { sendImage: async () => {} }); - const reply = await actionEditGraphic('phone-7', 'img_1', { cta: '20% off' }, { sendImage }); - - assert.match(reply, /couldn't send the image right now/); - assert.doesNotMatch(reply, /something went wrong generating/); - - const image = findTrackedImage('phone-7', 'img_1'); - assert.deepEqual(image.currentEdits, { cta: '20% off' }); + assert.match(reply, /couldn't find that image/); }); -test('actionSelectTvModel returns the 3 fixed TV model options with the question body text', () => { - const result = actionSelectTvModel('img_1'); - - assert.equal(result.type, 'edit_options'); - assert.equal(result.bodyText, 'Which model would you like to use?'); - assert.equal(result.options.length, 3); - assert.deepEqual( - result.options.map((option) => option.title), - ['Sony Bravia K-75', 'LG UA82 AI', 'Samsung UA4'] - ); -}); +test('routes a local-source image to the canned flow (no Express API call)', async () => { + writeOnamFixture(); + const sent = []; + const sendImage = async (_to, link) => sent.push(link); + await actionCreateDesign('phone-r3', { occasion: 'Onam' }, { sendImage }); -test('actionSelectTvModel encodes the same fixed productImage/oldPrice/price edits into every option id', () => { - const result = actionSelectTvModel('img_1'); + const reply = await actionEditGraphic('phone-r3', 'local_1', { address: 'MG Road' }, { sendImage }); - for (const option of result.options) { - const parsed = parseEditOptionId(option.id); - assert.deepEqual(parsed, { - imageId: 'img_1', - edits: { - productImage: 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000', - oldPrice: 33999, - price: 27199, - }, - }); - } + assert.match(reply, /Updated/); + assert.equal(sent.at(-1), 'https://cdn.test/f.png'); // canned "final" URL — proves the local flow ran }); diff --git a/src/editOptions.js b/src/editOptions.js new file mode 100644 index 0000000000..6f994272f5 --- /dev/null +++ b/src/editOptions.js @@ -0,0 +1,42 @@ +// Shared presentation helpers for the "allowed edits" summary and the WhatsApp +// quick-reply edit buttons. Pure functions with no I/O — used by BOTH the +// express flow and the local flow, so neither flow has to depend on the other. + +function formatAllowedEdits(name, elements, { includeInstruction = true } = {}) { + const lines = elements.map((element) => + element.type === 'text' + ? `- ${element.name}: currently "${element.value}"` + : `- ${element.name} (${element.type})` + ); + let text = `Edits allowed on "${name}":\n${lines.join('\n')}`; + if (includeInstruction) { + const example = elements[0]?.name || 'a field'; + text += `\nTell me what you'd like to change and to what, e.g. "change ${example} to ...".`; + } + return text; +} + +function humanizeFieldName(name) { + return name + .replace(/_/g, ' ') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .toLowerCase(); +} + +// WhatsApp reply-button titles are capped at 20 characters; trim on a word boundary +// rather than cutting mid-word. +function truncateTitle(title, maxLength = 20) { + if (title.length <= maxLength) return title; + const truncated = title.slice(0, maxLength); + const lastSpace = truncated.lastIndexOf(' '); + return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated; +} + +function buildEditOptions(elements, imageId) { + return elements.map((element) => ({ + id: `edit:${imageId}:${element.name}`, + title: truncateTitle(`Change ${humanizeFieldName(element.name)}`), + })); +} + +module.exports = { formatAllowedEdits, buildEditOptions }; diff --git a/src/express/expressApi.js b/src/express/expressApi.js index 9cc154df9c..d6a579d6e4 100644 --- a/src/express/expressApi.js +++ b/src/express/expressApi.js @@ -1,4 +1,7 @@ const { buildAuthHeaders } = require('./expressAuth'); +// Presentation helpers live in a shared module; re-exported here for backward +// compatibility with existing callers/tests. +const { formatAllowedEdits, buildEditOptions } = require('../editOptions'); function apiBaseUrl() { return process.env.EXPRESS_API_BASE_URL || 'https://express-api.adobe.io'; @@ -69,43 +72,6 @@ function collectTaggedElements(taggedDocument) { return elements; } -function formatAllowedEdits(name, elements, { includeInstruction = true } = {}) { - const lines = elements.map((element) => - element.type === 'text' - ? `- ${element.name}: currently "${element.value}"` - : `- ${element.name} (${element.type})` - ); - let text = `Edits allowed on "${name}":\n${lines.join('\n')}`; - if (includeInstruction) { - const example = elements[0]?.name || 'a field'; - text += `\nTell me what you'd like to change and to what, e.g. "change ${example} to ...".`; - } - return text; -} - -function humanizeFieldName(name) { - return name - .replace(/_/g, ' ') - .replace(/([a-z0-9])([A-Z])/g, '$1 $2') - .toLowerCase(); -} - -// WhatsApp reply-button titles are capped at 20 characters; trim on a word boundary -// rather than cutting mid-word. -function truncateTitle(title, maxLength = 20) { - if (title.length <= maxLength) return title; - const truncated = title.slice(0, maxLength); - const lastSpace = truncated.lastIndexOf(' '); - return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated; -} - -function buildEditOptions(elements, imageId) { - return elements.map((element) => ({ - id: `edit:${imageId}:${element.name}`, - title: truncateTitle(`Change ${humanizeFieldName(element.name)}`), - })); -} - function pagesForEdits(elements, editKeys) { const pageNumbers = new Set( elements.filter((element) => editKeys.includes(element.name)).map((element) => element.pageNumber) diff --git a/src/expressFlow.js b/src/expressFlow.js new file mode 100644 index 0000000000..8beb89623b --- /dev/null +++ b/src/expressFlow.js @@ -0,0 +1,125 @@ +// ── Flow 1: Adobe Express-backed catalog designs (image.source === 'express') ─ +// Real Adobe Express API: read the tagged document, validate edits against the +// live tagged elements, generate a variation, poll, and send the rendered image. +// +// Self-contained: this module must NOT depend on the local/canned flow. It is +// reached only via the router in actions.js for images whose source is 'express'. + +const { recordEdits } = require('./imageStore'); +const expressApi = require('./express/expressApi'); +const { buildValueEditId } = require('./interactiveReply'); +const { buildEditOptions, formatAllowedEdits } = require('./editOptions'); + +// A "change the product to a TV" request offers 3 fixed models as quick replies. +const TV_PLACEHOLDER_IMAGE_URL = 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000'; +const TV_MODEL_TITLES = ['Sony Bravia K-75', 'LG UA82 AI', 'Samsung UA4']; +const TV_MODEL_EDITS = { productImage: TV_PLACEHOLDER_IMAGE_URL, oldPrice: 33999, price: 27199 }; + +const MAX_DISCOUNT_PERCENT = 40; + +function withCurrentEdits(elements, currentEdits) { + return elements.map((element) => + element.name in currentEdits ? { ...element, value: currentEdits[element.name] } : element + ); +} + +function isDiscountField(name) { + return /discount/i.test(name); +} + +function parsePercent(value) { + const match = String(value).match(/(\d+(?:\.\d+)?)/); + return match ? Number(match[1]) : null; +} + +// TV model picker — each option id encodes the full productImage/price edits so a +// tap tells GPT exactly what to apply (see interactiveReply.buildValueEditId). +function selectTvModel(imageId) { + return { + type: 'edit_options', + bodyText: 'Which model would you like to use?', + options: TV_MODEL_TITLES.map((title) => ({ + id: buildValueEditId(imageId, TV_MODEL_EDITS), + title, + })), + }; +} + +// What can be edited? — reads the live tagged document from Adobe Express. +async function checkAllowedEdits(image) { + try { + const doc = await expressApi.getTaggedDocument(image.docId); + const elements = expressApi.collectTaggedElements(doc); + const elementsWithCurrentEdits = withCurrentEdits(elements, image.currentEdits); + return { + type: 'edit_options', + bodyText: 'What would you like to change?', + options: buildEditOptions(elementsWithCurrentEdits, image.id), + historyText: formatAllowedEdits(image.name, elementsWithCurrentEdits), + }; + } catch (err) { + console.error('[expressFlow.checkAllowedEdits] Express API error', { docId: image.docId, message: err.message }); + return `Sorry, I couldn't check the allowed edits for "${image.name}" right now. Please try again in a moment.`; + } +} + +// Apply edits via the real Adobe Express generate-variation pipeline. +async function editGraphic(phoneNumber, image, edits, { sendImage }) { + let elements; + try { + const doc = await expressApi.getTaggedDocument(image.docId); + elements = expressApi.collectTaggedElements(doc); + } catch (err) { + console.error('[expressFlow.editGraphic] Express API error', { docId: image.docId, message: err.message }); + return `Sorry, I couldn't reach Adobe Express to apply that edit. Please try again in a moment.`; + } + + const allowedNames = elements.map((element) => element.name); + const requestedKeys = Object.keys(edits || {}); + const disallowedKeys = requestedKeys.filter((key) => !allowedNames.includes(key)); + + if (disallowedKeys.length > 0) { + const elementsWithCurrentEdits = withCurrentEdits(elements, image.currentEdits); + return `I can't edit ${disallowedKeys.join(', ')} on "${image.name}". ${formatAllowedEdits(image.name, elementsWithCurrentEdits)}`; + } + + const oversizedDiscountKeys = requestedKeys.filter((key) => { + if (!isDiscountField(key)) return false; + const percent = parsePercent(edits[key]); + return percent !== null && percent > MAX_DISCOUNT_PERCENT; + }); + + if (oversizedDiscountKeys.length > 0) { + return `The maximum discount I can apply on "${image.name}" is ${MAX_DISCOUNT_PERCENT}%. Try again with ${MAX_DISCOUNT_PERCENT}% or less.`; + } + + const mergedEdits = { ...image.currentEdits, ...edits }; + const pages = expressApi.pagesForEdits(elements, Object.keys(mergedEdits)); + const preferredDocumentName = expressApi.buildPreferredDocumentName(image.name); + + let thumbnailUrl; + try { + const { statusUrl } = await expressApi.generateVariation(image.docId, mergedEdits, pages, preferredDocumentName); + const result = await expressApi.pollJobStatus(statusUrl); + thumbnailUrl = result.document.thumbnailUrl; + console.log('[edit:express] resolved image', { imageId: image.id, docId: image.docId, thumbnailUrl }); + } catch (err) { + console.error('[expressFlow.editGraphic] generate/poll error', { docId: image.docId, message: err.message }); + return `Sorry, something went wrong generating your updated "${image.name}". Please try again.`; + } + + recordEdits(phoneNumber, image.id, edits); + + const summary = Object.entries(edits).map(([key, value]) => `• ${key}: ${value}`).join('\n'); + + try { + await sendImage(phoneNumber, thumbnailUrl); + } catch (err) { + console.error('[expressFlow.editGraphic] sendImage error', { docId: image.docId, message: err.message }); + return `Updated "${image.name}", but I couldn't send the image right now — try asking me to resend it.`; + } + + return `Updated "${image.name}":\n${summary}`; +} + +module.exports = { selectTvModel, checkAllowedEdits, editGraphic, MAX_DISCOUNT_PERCENT }; diff --git a/src/expressFlow.test.js b/src/expressFlow.test.js new file mode 100644 index 0000000000..0276db81a8 --- /dev/null +++ b/src/expressFlow.test.js @@ -0,0 +1,177 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const expressFlow = require('./expressFlow'); +const expressApi = require('./express/expressApi'); +const { findTrackedImage, recordEdits } = require('./imageStore'); +const { parseEditOptionId } = require('./interactiveReply'); + +function writeFixtureCatalog(entries) { + const fixturePath = path.join(os.tmpdir(), `express-templates-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + fs.writeFileSync(fixturePath, JSON.stringify(entries)); + process.env.EXPRESS_TEMPLATES_FILE = fixturePath; +} + +const SAMPLE_ELEMENTS_DOC = { + documentPages: [ + { + pageNumber: 1, + taggedElements: [ + { name: 'heading', type: 'text', value: 'The X-Phone Pro is here!' }, + { name: 'cta', type: 'text', value: 'Available at our store starting 15 Aug 20XX.' }, + ], + }, + ], +}; + +// A catalog (source: 'express') image resolved through imageStore. +function catalogImage(phone) { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + return findTrackedImage(phone, 'img_1'); +} + +test('checkAllowedEdits lists the tagged elements for a known image', async () => { + expressApi.getTaggedDocument = async (docId) => { + assert.equal(docId, 'urn:doc:1'); + return SAMPLE_ELEMENTS_DOC; + }; + const image = catalogImage('phone-1'); + + const reply = await expressFlow.checkAllowedEdits(image); + + assert.match(reply.historyText, /Croma Earbuds/); + assert.match(reply.historyText, /heading: currently "The X-Phone Pro is here!"/); + assert.match(reply.historyText, /cta: currently/); +}); + +test('checkAllowedEdits shows the latest edited value instead of the stale document value', async () => { + expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + recordEdits('phone-1b', 'img_1', { cta: '20% off' }); + const image = findTrackedImage('phone-1b', 'img_1'); + + const reply = await expressFlow.checkAllowedEdits(image); + + assert.match(reply.historyText, /cta: currently "20% off"/); + assert.doesNotMatch(reply.historyText, /Available at our store starting 15 Aug 20XX\./); + assert.match(reply.historyText, /heading: currently "The X-Phone Pro is here!"/); +}); + +test('checkAllowedEdits returns a friendly message when the Express API call fails', async () => { + expressApi.getTaggedDocument = async () => { + throw new Error('getTaggedDocument failed 500: boom'); + }; + const image = catalogImage('phone-3'); + + const reply = await expressFlow.checkAllowedEdits(image); + + assert.match(reply, /couldn't check the allowed edits/); +}); + +test('editGraphic rejects edits outside the tagged elements and makes no generate call', async () => { + expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; + expressApi.generateVariation = async () => { + throw new Error('should not be called'); + }; + let sendImageCalled = false; + const sendImage = async () => { sendImageCalled = true; }; + const image = catalogImage('phone-4'); + + const reply = await expressFlow.editGraphic('phone-4', image, { background_color: 'red' }, { sendImage }); + + assert.match(reply, /can't edit background_color/); + assert.equal(sendImageCalled, false); +}); + +test('editGraphic applies an allowed edit end-to-end: generates, polls, sends the thumbnail, and records the edit', async () => { + expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; + expressApi.generateVariation = async (docId, tagMappings, pages, preferredDocumentName) => { + assert.equal(docId, 'urn:doc:1'); + assert.deepEqual(tagMappings, { cta: '20% off' }); + assert.equal(pages, '1'); + assert.match(preferredDocumentName, /^Croma Earbuds-edit-\d+$/); + return { jobId: 'job-1', statusUrl: 'https://express-api.adobe.io/status/job-1' }; + }; + expressApi.pollJobStatus = async (statusUrl) => { + assert.equal(statusUrl, 'https://express-api.adobe.io/status/job-1'); + return { status: 'succeeded', document: { thumbnailUrl: 'https://example.com/thumb.png' } }; + }; + + const sentCalls = []; + const sendImage = async (to, link) => { sentCalls.push({ to, link }); }; + const image = catalogImage('phone-5'); + + const reply = await expressFlow.editGraphic('phone-5', image, { cta: '20% off' }, { sendImage }); + + assert.match(reply, /Updated "Croma Earbuds"/); + assert.equal(sentCalls.length, 1); + assert.equal(sentCalls[0].to, 'phone-5'); + assert.equal(sentCalls[0].link, 'https://example.com/thumb.png'); + + const updated = findTrackedImage('phone-5', 'img_1'); + assert.deepEqual(updated.currentEdits, { cta: '20% off' }); +}); + +test('editGraphic returns a friendly message and does not record the edit when generation fails', async () => { + expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; + expressApi.generateVariation = async () => { throw new Error('generateVariation failed 500: boom'); }; + + let sendImageCalled = false; + const sendImage = async () => { sendImageCalled = true; }; + const image = catalogImage('phone-6'); + + const reply = await expressFlow.editGraphic('phone-6', image, { cta: '20% off' }, { sendImage }); + + assert.match(reply, /something went wrong generating/); + assert.equal(sendImageCalled, false); + + const updated = findTrackedImage('phone-6', 'img_1'); + assert.deepEqual(updated.currentEdits, {}); +}); + +test('editGraphic tells the user delivery failed but keeps the recorded edit when sendImage throws', async () => { + expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; + expressApi.generateVariation = async () => ({ jobId: 'job-1', statusUrl: 'https://express-api.adobe.io/status/job-1' }); + expressApi.pollJobStatus = async () => ({ status: 'succeeded', document: { thumbnailUrl: 'https://example.com/thumb.png' } }); + + const sendImage = async () => { throw new Error('WhatsApp could not fetch the link'); }; + const image = catalogImage('phone-7'); + + const reply = await expressFlow.editGraphic('phone-7', image, { cta: '20% off' }, { sendImage }); + + assert.match(reply, /couldn't send the image right now/); + assert.doesNotMatch(reply, /something went wrong generating/); + + const updated = findTrackedImage('phone-7', 'img_1'); + assert.deepEqual(updated.currentEdits, { cta: '20% off' }); +}); + +test('selectTvModel returns the 3 fixed TV model options with the question body text', () => { + const result = expressFlow.selectTvModel('img_1'); + + assert.equal(result.type, 'edit_options'); + assert.equal(result.bodyText, 'Which model would you like to use?'); + assert.equal(result.options.length, 3); + assert.deepEqual( + result.options.map((option) => option.title), + ['Sony Bravia K-75', 'LG UA82 AI', 'Samsung UA4'] + ); +}); + +test('selectTvModel encodes the same fixed productImage/oldPrice/price edits into every option id', () => { + const result = expressFlow.selectTvModel('img_1'); + + for (const option of result.options) { + const parsed = parseEditOptionId(option.id); + assert.deepEqual(parsed, { + imageId: 'img_1', + edits: { + productImage: 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000', + oldPrice: 33999, + price: 27199, + }, + }); + } +}); diff --git a/src/localFlow.js b/src/localFlow.js new file mode 100644 index 0000000000..fca1be8e9c --- /dev/null +++ b/src/localFlow.js @@ -0,0 +1,150 @@ +// ── Flow 2: canned-image designs created at runtime (image.source === 'local') ─ +// No Adobe Express calls. A design is created from a text description and its +// edits resolve to pre-hosted image URLs defined in data/onam-design.json. +// +// Self-contained: this module must NOT depend on the express flow. It is reached +// only via the router in actions.js for images whose source is 'local'. + +const fs = require('node:fs'); +const path = require('node:path'); +const { recordEdits, createDesign: registerDesign } = require('./imageStore'); +const { buildEditOptions, formatAllowedEdits } = require('./editOptions'); + +function loadOnamDesign() { + const filePath = process.env.ONAM_DESIGN_FILE || path.join(__dirname, '..', 'data', 'onam-design.json'); + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +// create_design: register a brand-new local design and send its base image. +async function createDesign(phoneNumber, { occasion, products, offer } = {}, { sendImage }) { + const design = loadOnamDesign(); + const productList = Array.isArray(products) && products.length ? products.join(' + ') : (products || 'your products'); + const name = [occasion || 'Festive', productList, 'offer'].filter(Boolean).join(' '); + console.log('[action:create_design]', { phoneNumber, occasion, products, offer, image: design.images.base }); + registerDesign(phoneNumber, { name, design }); + + try { + await sendImage(phoneNumber, design.images.base); + } catch (err) { + console.error('[localFlow.createDesign] sendImage error', { message: err.message }); + return `I built your ${occasion || 'festive'} design, but couldn't send the image right now — try asking me to resend it.`; + } + + return `Here you go 🌼 Built with Croma's logo, approved festive colours and the ${productList} images. Want to tweak anything?`; +} + +function normalizeKey(key) { + return String(key).toLowerCase().trim().replace(/\s+/g, '_'); +} + +// Map GPT's free-form edit keys onto the design's canonical slot names via +// aliases, so "background_color" / "colour" / "heading" all resolve correctly. +function canonicalizeEdits(editableSlots, edits) { + const canonical = {}; + const unknown = []; + for (const [rawKey, value] of Object.entries(edits || {})) { + const key = normalizeKey(rawKey); + const slot = editableSlots.find( + (s) => normalizeKey(s.name) === key || (s.aliases || []).some((a) => normalizeKey(a) === key) + ); + if (slot) canonical[slot.name] = value; + else unknown.push(rawKey); + } + return { canonical, unknown }; +} + +function isPaletteColor(design, value) { + const v = String(value).trim().toLowerCase(); + return (design.palette || []).some((c) => c.name.toLowerCase() === v || c.hex.toLowerCase() === v); +} + +function hasMalayalam(value) { + return /[ഀ-ൿ]/.test(String(value)); +} + +function isMalayalamEdit(currentEdits) { + return Object.entries(currentEdits).some(([key, value]) => + hasMalayalam(String(value)) || (key === 'language' && /malayalam/i.test(String(value))) + ); +} + +// Pick the canned image for the current accumulated edit state. +function resolveLocalImage(design, currentEdits) { + if (isMalayalamEdit(currentEdits)) return design.images.malayalam; + if (currentEdits.background || currentEdits.address) return design.images.final; + return design.images.base; +} + +function localEditElements(image) { + return image.design.slots.editable.map((slot) => ({ + name: slot.name, + type: slot.type || 'text', + value: image.currentEdits[slot.name] ?? '', + })); +} + +// What can be edited? — from the design's static slot schema (no API). +function checkAllowedEdits(image) { + const elements = localEditElements(image); + return { + type: 'edit_options', + bodyText: 'What would you like to change?', + options: buildEditOptions(elements, image.id), + historyText: formatAllowedEdits(image.name, elements), + }; +} + +// Apply edits by resolving to the matching canned image URL. +async function editGraphic(phoneNumber, image, rawEdits, { sendImage }) { + const design = image.design; + const editableSlots = design.slots.editable; + + // Translation is special: the model may pass the Malayalam text (or the word + // "Malayalam") under any key. Detect it up front so a "translate to Malayalam" + // request always maps to the Malayalam creative, regardless of the edit key. + const rawText = Object.entries(rawEdits || {}).flat().map(String); + const wantsMalayalam = rawText.some(hasMalayalam) || rawText.some((s) => /malayalam/i.test(s)); + + // A translation request short-circuits everything: map straight to the + // Malayalam creative. Never treat it as a field edit or run it through the + // palette / locked-field guardrails (the model may put the Malayalam text on + // any key, including one that looks like "background"). + let appliedEdits; + if (wantsMalayalam) { + appliedEdits = { language: 'Malayalam' }; + } else { + const { canonical: edits, unknown } = canonicalizeEdits(editableSlots, rawEdits); + + if (unknown.length > 0) { + const editableNames = editableSlots.map((s) => s.name).join(', '); + return `I can't edit ${unknown.join(', ')} on "${image.name}" — those are locked by HQ. You can change: ${editableNames}.`; + } + + if ('background' in edits && !isPaletteColor(design, edits.background)) { + const options = design.palette.map((c) => c.name).join(' · '); + return `"${edits.background}" isn't in the approved palette 🙂 Here are the festive accents you can pick from: ${options}`; + } + + appliedEdits = edits; + } + + recordEdits(phoneNumber, image.id, appliedEdits); + const currentEdits = { ...image.currentEdits, ...appliedEdits }; + const imageUrl = resolveLocalImage(design, currentEdits); + console.log('[edit:local] resolved image', { imageId: image.id, currentEdits, imageUrl }); + + const summary = wantsMalayalam + ? '• translated to Malayalam' + : Object.entries(appliedEdits).map(([key, value]) => `• ${key}: ${value}`).join('\n'); + + try { + await sendImage(phoneNumber, imageUrl); + } catch (err) { + console.error('[localFlow.editGraphic] sendImage error', { imageId: image.id, message: err.message }); + return `Updated "${image.name}", but I couldn't send the image right now — try asking me to resend it.`; + } + + return `Updated "${image.name}":\n${summary}`; +} + +module.exports = { createDesign, checkAllowedEdits, editGraphic }; diff --git a/src/createDesign.test.js b/src/localFlow.test.js similarity index 51% rename from src/createDesign.test.js rename to src/localFlow.test.js index 5165ceff62..4cd05410f2 100644 --- a/src/createDesign.test.js +++ b/src/localFlow.test.js @@ -3,7 +3,8 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); -const { actionCreateDesign, actionEditGraphic } = require('./actions'); +const localFlow = require('./localFlow'); +const { findTrackedImage } = require('./imageStore'); const FIXTURE = { images: { @@ -37,11 +38,19 @@ function captureSendImage() { return { sendImage: async (_to, link) => sent.push(link), sent }; } -test('actionCreateDesign registers a local design and sends the base image', async () => { +// Create a design for a phone (base image sent) and return its tracked image object. +async function setup(phone, args = { occasion: 'Onam' }) { + useFixture(); + const { sendImage, sent } = captureSendImage(); + await localFlow.createDesign(phone, args, { sendImage }); + return { image: findTrackedImage(phone, 'local_1'), sendImage, sent }; +} + +test('createDesign registers a local design and sends the base image', async () => { useFixture(); const { sendImage, sent } = captureSendImage(); - const reply = await actionCreateDesign( + const reply = await localFlow.createDesign( 'onam-phone-1', { occasion: 'Onam', products: ['LG washing machine', 'dishwasher'], offer: '20% off' }, { sendImage } @@ -51,12 +60,20 @@ test('actionCreateDesign registers a local design and sends the base image', asy assert.match(reply, /Built with Croma's logo/); }); -test('editing a created design to an off-palette background is rejected with approved options', async () => { - useFixture(); - const { sendImage, sent } = captureSendImage(); - await actionCreateDesign('onam-phone-2', { occasion: 'Onam' }, { sendImage }); +test('checkAllowedEdits lists the editable slots from the static schema', async () => { + const { image } = await setup('onam-phone-check'); - const reply = await actionEditGraphic('onam-phone-2', 'local_1', { background: 'Pink' }, { sendImage }); + const result = localFlow.checkAllowedEdits(image); + + assert.equal(result.type, 'edit_options'); + assert.deepEqual(result.options.map((o) => o.title), ['Change headline', 'Change background', 'Change address']); + assert.match(result.historyText, /headline/); +}); + +test('editing to an off-palette background is rejected with the approved options', async () => { + const { image, sent } = await setup('onam-phone-2'); + + const reply = await localFlow.editGraphic('onam-phone-2', image, { background: 'Pink' }, { sendImage: async (_t, l) => sent.push(l) }); assert.match(reply, /isn't in the approved palette/); assert.match(reply, /Marigold · Maroon · Deep Green/); @@ -64,85 +81,67 @@ test('editing a created design to an off-palette background is rejected with app }); test('an approved-palette background + address resolves to the final image', async () => { - useFixture(); - const { sendImage, sent } = captureSendImage(); - await actionCreateDesign('onam-phone-3', { occasion: 'Onam' }, { sendImage }); + const { image, sent } = await setup('onam-phone-3'); - await actionEditGraphic('onam-phone-3', 'local_1', { background: 'Marigold', address: 'MG Road, Kochi' }, { sendImage }); + await localFlow.editGraphic('onam-phone-3', image, { background: 'Marigold', address: 'MG Road, Kochi' }, { sendImage: async (_t, l) => sent.push(l) }); assert.equal(sent.at(-1), FIXTURE.images.final); }); test('a Malayalam headline resolves to the Malayalam image', async () => { - useFixture(); - const { sendImage, sent } = captureSendImage(); - await actionCreateDesign('onam-phone-4', { occasion: 'Onam' }, { sendImage }); + const { image, sent } = await setup('onam-phone-4'); - await actionEditGraphic('onam-phone-4', 'local_1', { headline: 'ഓണം ആശംസകൾ' }, { sendImage }); + await localFlow.editGraphic('onam-phone-4', image, { headline: 'ഓണം ആശംസകൾ' }, { sendImage: async (_t, l) => sent.push(l) }); assert.equal(sent.at(-1), FIXTURE.images.malayalam); }); test('adding only a store address resolves to the final image (demo msg 2)', async () => { - useFixture(); - const { sendImage, sent } = captureSendImage(); - await actionCreateDesign('onam-phone-a', { occasion: 'Onam' }, { sendImage }); + const { image, sent } = await setup('onam-phone-a'); - await actionEditGraphic('onam-phone-a', 'local_1', { address: 'Princess Street, Kochi' }, { sendImage }); + await localFlow.editGraphic('onam-phone-a', image, { address: 'Princess Street, Kochi' }, { sendImage: async (_t, l) => sent.push(l) }); assert.equal(sent.at(-1), FIXTURE.images.final); }); test('a translate request under an unrecognized key still resolves to Malayalam (demo msg 3)', async () => { - useFixture(); - const { sendImage, sent } = captureSendImage(); - await actionCreateDesign('onam-phone-b', { occasion: 'Onam' }, { sendImage }); + const { image, sent } = await setup('onam-phone-b'); - // model phrases it as a language change rather than a headline edit - const reply = await actionEditGraphic('onam-phone-b', 'local_1', { language: 'Malayalam' }, { sendImage }); + const reply = await localFlow.editGraphic('onam-phone-b', image, { language: 'Malayalam' }, { sendImage: async (_t, l) => sent.push(l) }); assert.equal(sent.at(-1), FIXTURE.images.malayalam); assert.doesNotMatch(reply, /locked by HQ/); }); test('a Malayalam value under any key resolves to Malayalam', async () => { - useFixture(); - const { sendImage, sent } = captureSendImage(); - await actionCreateDesign('onam-phone-c', { occasion: 'Onam' }, { sendImage }); + const { image, sent } = await setup('onam-phone-c'); - await actionEditGraphic('onam-phone-c', 'local_1', { banner: 'ഓണം ആശംസകൾ' }, { sendImage }); + await localFlow.editGraphic('onam-phone-c', image, { banner: 'ഓണം ആശംസകൾ' }, { sendImage: async (_t, l) => sent.push(l) }); assert.equal(sent.at(-1), FIXTURE.images.malayalam); }); test('a Malayalam value landing on the background key translates, not palette-rejected (regression)', async () => { - useFixture(); - const { sendImage, sent } = captureSendImage(); - await actionCreateDesign('onam-phone-d', { occasion: 'Onam' }, { sendImage }); + const { image, sent } = await setup('onam-phone-d'); - // model mistakenly puts the translated word on a "background"-like key - const reply = await actionEditGraphic('onam-phone-d', 'local_1', { background: 'ഓണം' }, { sendImage }); + const reply = await localFlow.editGraphic('onam-phone-d', image, { background: 'ഓണം' }, { sendImage: async (_t, l) => sent.push(l) }); assert.equal(sent.at(-1), FIXTURE.images.malayalam); assert.doesNotMatch(reply, /approved palette/); }); -test('editing a locked element on a created design is refused', async () => { - useFixture(); - const { sendImage } = captureSendImage(); - await actionCreateDesign('onam-phone-5', { occasion: 'Onam' }, { sendImage }); +test('editing a locked element is refused', async () => { + const { image } = await setup('onam-phone-5'); - const reply = await actionEditGraphic('onam-phone-5', 'local_1', { logo: 'brighter' }, { sendImage }); + const reply = await localFlow.editGraphic('onam-phone-5', image, { logo: 'brighter' }, { sendImage: async () => {} }); assert.match(reply, /locked by HQ/); }); test('edit-key aliases map onto canonical slot names (colour -> background)', async () => { - useFixture(); - const { sendImage, sent } = captureSendImage(); - await actionCreateDesign('onam-phone-6', { occasion: 'Onam' }, { sendImage }); + const { image, sent } = await setup('onam-phone-6'); - await actionEditGraphic('onam-phone-6', 'local_1', { colour: 'Deep Green' }, { sendImage }); + await localFlow.editGraphic('onam-phone-6', image, { colour: 'Deep Green' }, { sendImage: async (_t, l) => sent.push(l) }); assert.equal(sent.at(-1), FIXTURE.images.final); }); From 1616d3dd16897a7f8b3470a1f13696d607560a21 Mon Sep 17 00:00:00 2001 From: varun kalra Date: Wed, 22 Jul 2026 18:22:19 +0530 Subject: [PATCH 26/35] feat(local): guided create flow with button follow-ups and image captions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the dealer-guided local flow: describe products+offer → assistant suggests Onam → asks to add address → generates the creative → offer to change → translate to Malayalam. Uses only the onam-final and onam-malayalam creatives. - ask_for_more_information can now carry options (e.g. ["Yes","No"]) rendered as tappable WhatsApp reply buttons; the tapped title flows back as the user's text - create_design takes includeAddress; when true the with-address ("final") creative is sent (records the address so later edits keep that state) - images are now sent WITH a friendly caption describing the offer / the change, so text + image arrive together like a real promo - widen the GPT context window (last 3 → last 12) so multi-step follow-ups retain the original product/offer request; system prompt drives the guided flow - tests: cover includeAddress → final image, captions, and button-reply parsing Co-Authored-By: Claude Opus 4.8 (1M context) --- src/actions.test.js | 2 +- src/app.js | 46 ++++++++++++++++++++++++++------ src/localFlow.js | 39 +++++++++++++++++---------- src/localFlow.test.js | 61 ++++++++++++++++++++++++++++--------------- 4 files changed, 104 insertions(+), 44 deletions(-) diff --git a/src/actions.test.js b/src/actions.test.js index 2f0603f92b..26aef77d04 100644 --- a/src/actions.test.js +++ b/src/actions.test.js @@ -49,6 +49,6 @@ test('routes a local-source image to the canned flow (no Express API call)', asy const reply = await actionEditGraphic('phone-r3', 'local_1', { address: 'MG Road' }, { sendImage }); - assert.match(reply, /Updated/); + assert.match(reply, /anything else/i); assert.equal(sent.at(-1), 'https://cdn.test/f.png'); // canned "final" URL — proves the local flow ran }); diff --git a/src/app.js b/src/app.js index 276d602d3d..38460709bb 100644 --- a/src/app.js +++ b/src/app.js @@ -60,8 +60,9 @@ function sendText(to, text) { return whatsappPost({ messaging_product: 'whatsapp', to, type: 'text', text: { body: text } }); } -function sendImage(to, link) { - return whatsappPost({ messaging_product: 'whatsapp', to, type: 'image', image: { link } }); +function sendImage(to, link, caption) { + const image = caption ? { link, caption } : { link }; + return whatsappPost({ messaging_product: 'whatsapp', to, type: 'image', image }); } // WhatsApp reply-button messages support at most 3 buttons. @@ -95,6 +96,13 @@ async function sendEditOptions(to, { bodyText, options }) { } } +// Follow-up yes/no (or short multiple-choice) questions rendered as tappable +// buttons. The tapped title flows back as the user's text (see interactiveReply). +function sendQuickReplies(to, question, options) { + const buttons = options.slice(0, BUTTONS_PER_MESSAGE).map((label) => ({ id: `qr:${label}`, title: label })); + return sendButtons(to, question, buttons); +} + // ── GPT tool definitions ───────────────────────────────────────────────────── const tools = [ @@ -122,6 +130,10 @@ const tools = [ description: 'Products to feature, e.g. ["LG washing machine", "dishwasher"]', }, offer: { type: 'string', description: 'The offer or discount to show, e.g. "20% off"' }, + includeAddress: { + type: 'boolean', + description: "Set true if the user has agreed to include their store address on the design; otherwise false.", + }, }, required: ['occasion'], }, @@ -131,11 +143,16 @@ const tools = [ type: 'function', function: { name: 'ask_for_more_information', - description: 'Ask the user a clarifying question when the request is ambiguous or incomplete', + description: 'Ask the user a clarifying question when the request is ambiguous or incomplete. For yes/no or short multiple-choice questions, pass options so the user gets tappable buttons instead of typing.', parameters: { type: 'object', properties: { question: { type: 'string', description: 'The clarifying question to send to the user' }, + options: { + type: 'array', + items: { type: 'string' }, + description: 'Optional short answer choices to render as tappable buttons, e.g. ["Yes","No"] (max 3).', + }, }, required: ['question'], }, @@ -208,7 +225,9 @@ const tools = [ // ── GPT decision engine ────────────────────────────────────────────────────── async function decideAction(phoneNumber, userMessage) { - const last3 = getHistory(phoneNumber).slice(-3); + // Keep a wide window so multi-step flows (e.g. "design X" → "Onam? yes" → + // "address? yes" → create) still see the original product/offer request. + const recentHistory = getHistory(phoneNumber).slice(-12); const trackedImages = getTrackedImages(phoneNumber); const imagesList = trackedImages.map((image) => `- ${image.id}: ${image.name}`).join('\n'); @@ -220,7 +239,12 @@ Analyze the user's message and conversation history, then call the appropriate t Always call exactly one tool — never reply with plain text. If the request is ambiguous or missing details, use ask_for_more_information. If the user says which field they want to change but hasn't given the new value yet, call ask_for_more_information to ask what to change it to. If a later message in the conversation then supplies that value, call edit_graphic with the field and value instead of asking again. -If the user wants a brand-new creative for an occasion that has no existing template (e.g. Onam, Pongal), use create_design. +Creating a brand-new design (create_design), gather details first with tappable buttons: +- If the user asks for a design but does NOT mention an occasion/festival, first call ask_for_more_information with options ["Yes","No"] to suggest Onam, e.g. "This is a great time for an Onam offer — want me to generate this for Onam?". +- Once an occasion is agreed (or was given up front), and before creating, call ask_for_more_information with options ["Yes","No"] to ask "Great! Do you also want to add your store address?". +- Then call create_design with the occasion, the products and offer mentioned earlier in the conversation, and includeAddress set from their address answer. +- Always attach options ["Yes","No"] to any yes/no question so the user can tap a button instead of typing. +- After the design is sent, if the user asks to change something (e.g. "change the language to Malayalam"), call edit_graphic. Choosing between edit_graphic and check_allowed_edits: if the user's message already contains a concrete change and its value (e.g. "make the background marigold", "add my address MG Road Kochi"), call edit_graphic with all of those changes in the edits object. Only call check_allowed_edits when the user asks what can be changed or wants the list of options WITHOUT giving a specific value. When editing, prefer these field names when they apply: headline, background, address, offer. If the user asks to translate a tag's text into another language (e.g. "change the headline to Hindi", "translate the banner to Malayalam"), translate the current text yourself before calling edit_graphic and pass the translated text as the edit value. For Hindi, use Devanagari script (e.g. "उपलब्ध"); for Malayalam, use Malayalam script (e.g. "ഓണം"). Never use a romanized/transliterated form. @@ -228,7 +252,7 @@ If the user asks to translate a tag's text into another language (e.g. "change t Images previously sent to this user (reference by id): ${imagesList}`, }, - ...last3, + ...recentHistory, { role: 'user', content: userMessage }, ]; @@ -306,8 +330,14 @@ app.post('/', async (req, res) => { break; case 'ask_for_more_information': - console.log('[action:ask_for_more_information]', { question: args.question }); - replyText = args.question; + console.log('[action:ask_for_more_information]', { question: args.question, options: args.options }); + if (Array.isArray(args.options) && args.options.length > 0) { + await sendQuickReplies(phoneNumber, args.question, args.options); + replyText = args.question; // kept for conversation history + skipSend = true; + } else { + replyText = args.question; + } break; case 'check_allowed_edits': { diff --git a/src/localFlow.js b/src/localFlow.js index fca1be8e9c..e23e516164 100644 --- a/src/localFlow.js +++ b/src/localFlow.js @@ -15,22 +15,33 @@ function loadOnamDesign() { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } -// create_design: register a brand-new local design and send its base image. -async function createDesign(phoneNumber, { occasion, products, offer } = {}, { sendImage }) { +// create_design: register a brand-new local design and send its image with a +// friendly caption. If the user opted to include their address, the creative +// that carries it is the "final" image (we only ship 2 canned URLs for this flow). +async function createDesign(phoneNumber, { occasion, products, offer, includeAddress } = {}, { sendImage }) { const design = loadOnamDesign(); const productList = Array.isArray(products) && products.length ? products.join(' + ') : (products || 'your products'); - const name = [occasion || 'Festive', productList, 'offer'].filter(Boolean).join(' '); - console.log('[action:create_design]', { phoneNumber, occasion, products, offer, image: design.images.base }); - registerDesign(phoneNumber, { name, design }); + const festive = occasion || 'Festive'; + const name = [festive, productList, 'offer'].filter(Boolean).join(' '); + const image = registerDesign(phoneNumber, { name, design }); + + const imageUrl = includeAddress ? design.images.final : design.images.base; + if (includeAddress) recordEdits(phoneNumber, image.id, { address: 'your store' }); + + console.log('[action:create_design]', { phoneNumber, occasion, products, offer, includeAddress, image: imageUrl }); + + const offerText = offer ? ` at ${offer}` : ''; + const addressText = includeAddress ? ', with your store address' : ''; + const caption = `🌼 Happy ${festive}! Here's your festive creative — ${productList}${offerText}${addressText}. On-brand with Croma's logo and approved colours, ready to share. ✨`; try { - await sendImage(phoneNumber, design.images.base); + await sendImage(phoneNumber, imageUrl, caption); } catch (err) { console.error('[localFlow.createDesign] sendImage error', { message: err.message }); - return `I built your ${occasion || 'festive'} design, but couldn't send the image right now — try asking me to resend it.`; + return `I built your ${festive} design, but couldn't send the image right now — try asking me to resend it.`; } - return `Here you go 🌼 Built with Croma's logo, approved festive colours and the ${productList} images. Want to tweak anything?`; + return 'Want to change anything? For example, I can translate the whole banner to Malayalam. 🌸'; } function normalizeKey(key) { @@ -133,18 +144,18 @@ async function editGraphic(phoneNumber, image, rawEdits, { sendImage }) { const imageUrl = resolveLocalImage(design, currentEdits); console.log('[edit:local] resolved image', { imageId: image.id, currentEdits, imageUrl }); - const summary = wantsMalayalam - ? '• translated to Malayalam' - : Object.entries(appliedEdits).map(([key, value]) => `• ${key}: ${value}`).join('\n'); + const caption = wantsMalayalam + ? '🌸 Here you go — your banner is now in Malayalam!' + : `✅ Done! Updated ${Object.entries(appliedEdits).map(([key, value]) => `${key} → ${value}`).join(', ')}.`; try { - await sendImage(phoneNumber, imageUrl); + await sendImage(phoneNumber, imageUrl, caption); } catch (err) { console.error('[localFlow.editGraphic] sendImage error', { imageId: image.id, message: err.message }); - return `Updated "${image.name}", but I couldn't send the image right now — try asking me to resend it.`; + return `I updated "${image.name}", but couldn't send the image right now — try asking me to resend it.`; } - return `Updated "${image.name}":\n${summary}`; + return "Anything else you'd like to change?"; } module.exports = { createDesign, checkAllowedEdits, editGraphic }; diff --git a/src/localFlow.test.js b/src/localFlow.test.js index 4cd05410f2..cccb7f42ee 100644 --- a/src/localFlow.test.js +++ b/src/localFlow.test.js @@ -33,12 +33,14 @@ function useFixture() { process.env.ONAM_DESIGN_FILE = p; } +// Captures both the image link and its caption so we can assert on the +// descriptive text sent alongside each image. function captureSendImage() { const sent = []; - return { sendImage: async (_to, link) => sent.push(link), sent }; + return { sendImage: async (_to, link, caption) => sent.push({ link, caption }), sent }; } -// Create a design for a phone (base image sent) and return its tracked image object. +// Create a design for a phone (image sent) and return its tracked image object. async function setup(phone, args = { occasion: 'Onam' }) { useFixture(); const { sendImage, sent } = captureSendImage(); @@ -46,7 +48,7 @@ async function setup(phone, args = { occasion: 'Onam' }) { return { image: findTrackedImage(phone, 'local_1'), sendImage, sent }; } -test('createDesign registers a local design and sends the base image', async () => { +test('createDesign registers a local design and sends the base image with a caption', async () => { useFixture(); const { sendImage, sent } = captureSendImage(); @@ -56,8 +58,24 @@ test('createDesign registers a local design and sends the base image', async () { sendImage } ); - assert.equal(sent[0], FIXTURE.images.base); - assert.match(reply, /Built with Croma's logo/); + assert.equal(sent[0].link, FIXTURE.images.base); + assert.match(sent[0].caption, /Onam/); + assert.match(sent[0].caption, /20% off/); + assert.match(reply, /change anything/i); +}); + +test('createDesign with includeAddress sends the with-address (final) image', async () => { + useFixture(); + const { sendImage, sent } = captureSendImage(); + + await localFlow.createDesign( + 'onam-phone-addr', + { occasion: 'Onam', products: ['Samsung Galaxy S26', 'Galaxy Buds 3'], offer: '20% off', includeAddress: true }, + { sendImage } + ); + + assert.equal(sent[0].link, FIXTURE.images.final); + assert.match(sent[0].caption, /store address/); }); test('checkAllowedEdits lists the editable slots from the static schema', async () => { @@ -73,7 +91,7 @@ test('checkAllowedEdits lists the editable slots from the static schema', async test('editing to an off-palette background is rejected with the approved options', async () => { const { image, sent } = await setup('onam-phone-2'); - const reply = await localFlow.editGraphic('onam-phone-2', image, { background: 'Pink' }, { sendImage: async (_t, l) => sent.push(l) }); + const reply = await localFlow.editGraphic('onam-phone-2', image, { background: 'Pink' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); assert.match(reply, /isn't in the approved palette/); assert.match(reply, /Marigold · Maroon · Deep Green/); @@ -83,50 +101,51 @@ test('editing to an off-palette background is rejected with the approved options test('an approved-palette background + address resolves to the final image', async () => { const { image, sent } = await setup('onam-phone-3'); - await localFlow.editGraphic('onam-phone-3', image, { background: 'Marigold', address: 'MG Road, Kochi' }, { sendImage: async (_t, l) => sent.push(l) }); + await localFlow.editGraphic('onam-phone-3', image, { background: 'Marigold', address: 'MG Road, Kochi' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); - assert.equal(sent.at(-1), FIXTURE.images.final); + assert.equal(sent.at(-1).link, FIXTURE.images.final); }); -test('a Malayalam headline resolves to the Malayalam image', async () => { +test('a Malayalam headline resolves to the Malayalam image with a Malayalam caption', async () => { const { image, sent } = await setup('onam-phone-4'); - await localFlow.editGraphic('onam-phone-4', image, { headline: 'ഓണം ആശംസകൾ' }, { sendImage: async (_t, l) => sent.push(l) }); + await localFlow.editGraphic('onam-phone-4', image, { headline: 'ഓണം ആശംസകൾ' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); - assert.equal(sent.at(-1), FIXTURE.images.malayalam); + assert.equal(sent.at(-1).link, FIXTURE.images.malayalam); + assert.match(sent.at(-1).caption, /Malayalam/); }); test('adding only a store address resolves to the final image (demo msg 2)', async () => { const { image, sent } = await setup('onam-phone-a'); - await localFlow.editGraphic('onam-phone-a', image, { address: 'Princess Street, Kochi' }, { sendImage: async (_t, l) => sent.push(l) }); + await localFlow.editGraphic('onam-phone-a', image, { address: 'Princess Street, Kochi' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); - assert.equal(sent.at(-1), FIXTURE.images.final); + assert.equal(sent.at(-1).link, FIXTURE.images.final); }); test('a translate request under an unrecognized key still resolves to Malayalam (demo msg 3)', async () => { const { image, sent } = await setup('onam-phone-b'); - const reply = await localFlow.editGraphic('onam-phone-b', image, { language: 'Malayalam' }, { sendImage: async (_t, l) => sent.push(l) }); + const reply = await localFlow.editGraphic('onam-phone-b', image, { language: 'Malayalam' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); - assert.equal(sent.at(-1), FIXTURE.images.malayalam); + assert.equal(sent.at(-1).link, FIXTURE.images.malayalam); assert.doesNotMatch(reply, /locked by HQ/); }); test('a Malayalam value under any key resolves to Malayalam', async () => { const { image, sent } = await setup('onam-phone-c'); - await localFlow.editGraphic('onam-phone-c', image, { banner: 'ഓണം ആശംസകൾ' }, { sendImage: async (_t, l) => sent.push(l) }); + await localFlow.editGraphic('onam-phone-c', image, { banner: 'ഓണം ആശംസകൾ' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); - assert.equal(sent.at(-1), FIXTURE.images.malayalam); + assert.equal(sent.at(-1).link, FIXTURE.images.malayalam); }); test('a Malayalam value landing on the background key translates, not palette-rejected (regression)', async () => { const { image, sent } = await setup('onam-phone-d'); - const reply = await localFlow.editGraphic('onam-phone-d', image, { background: 'ഓണം' }, { sendImage: async (_t, l) => sent.push(l) }); + const reply = await localFlow.editGraphic('onam-phone-d', image, { background: 'ഓണം' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); - assert.equal(sent.at(-1), FIXTURE.images.malayalam); + assert.equal(sent.at(-1).link, FIXTURE.images.malayalam); assert.doesNotMatch(reply, /approved palette/); }); @@ -141,7 +160,7 @@ test('editing a locked element is refused', async () => { test('edit-key aliases map onto canonical slot names (colour -> background)', async () => { const { image, sent } = await setup('onam-phone-6'); - await localFlow.editGraphic('onam-phone-6', image, { colour: 'Deep Green' }, { sendImage: async (_t, l) => sent.push(l) }); + await localFlow.editGraphic('onam-phone-6', image, { colour: 'Deep Green' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); - assert.equal(sent.at(-1), FIXTURE.images.final); + assert.equal(sent.at(-1).link, FIXTURE.images.final); }); From fd41439ec232f07a740c9882528c4172bbcbd3c7 Mon Sep 17 00:00:00 2001 From: varun kalra Date: Wed, 22 Jul 2026 18:40:13 +0530 Subject: [PATCH 27/35] feat(local): stream "work being done" and add a realistic generation delay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generation no longer returns instantly. create_design and the local edit path now stream progress messages one at a time (with a tunable pause) before sending the image, so it feels like real work. - streamProgress() sends step messages with a GEN_STEP_DELAY_MS pause (default 1800ms); no-op when no sendText is provided, so unit tests stay instant - create streams: creating → pulling brand kit → adding products → applying offer/styling → (if chosen) placing address, then the image - Malayalam edit streams: translating → re-rendering, then the image - messages are dynamic (name the actual products/offer; address step only when address was requested) - sendText is threaded through actions → both flows; app.js drops the old static pre-step texts; expressFlow shows a single guarded re-render notice - demo-onam-flow.sh waits for generation before the next step (GEN_PAUSE) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/actions.js | 6 +++--- src/app.js | 8 ++++---- src/expressFlow.js | 4 +++- src/localFlow.js | 36 ++++++++++++++++++++++++++++++++++-- src/localFlow.test.js | 35 +++++++++++++++++++++++++++++++++++ 5 files changed, 79 insertions(+), 10 deletions(-) diff --git a/src/actions.js b/src/actions.js index 90662359b3..17f186a094 100644 --- a/src/actions.js +++ b/src/actions.js @@ -43,14 +43,14 @@ async function actionCheckAllowedEdits(phoneNumber, imageId) { } // Router: resolve the image, then hand off to the flow that owns it. -async function actionEditGraphic(phoneNumber, imageId, edits, { sendImage }) { +async function actionEditGraphic(phoneNumber, imageId, edits, { sendImage, sendText }) { const image = findTrackedImage(phoneNumber, imageId); console.log('[action:edit_graphic]', { phoneNumber, imageId, source: image?.source ?? 'not_found', edits }); if (!image) return formatUnknownImageMessage(phoneNumber); return image.source === 'local' - ? localFlow.editGraphic(phoneNumber, image, edits, { sendImage }) - : expressFlow.editGraphic(phoneNumber, image, edits, { sendImage }); + ? localFlow.editGraphic(phoneNumber, image, edits, { sendImage, sendText }) + : expressFlow.editGraphic(phoneNumber, image, edits, { sendImage, sendText }); } async function actionGenerateBulkGraphics(filename) { diff --git a/src/app.js b/src/app.js index 38460709bb..7bc7806901 100644 --- a/src/app.js +++ b/src/app.js @@ -325,8 +325,8 @@ app.post('/', async (req, res) => { break; case 'create_design': - await sendText(phoneNumber, '🎨 Creating your design, this may take a moment...'); - replyText = await actionCreateDesign(phoneNumber, args, { sendImage }); + // Progress is streamed from inside the flow (with the product/offer context). + replyText = await actionCreateDesign(phoneNumber, args, { sendImage, sendText }); break; case 'ask_for_more_information': @@ -362,8 +362,8 @@ app.post('/', async (req, res) => { } case 'edit_graphic': - await sendText(phoneNumber, '⏳ Applying edits to your graphic...'); - replyText = await actionEditGraphic(phoneNumber, args.image_id, args.edits, { sendImage }); + // Progress is streamed from inside the flow. + replyText = await actionEditGraphic(phoneNumber, args.image_id, args.edits, { sendImage, sendText }); break; case 'generate_bulk_graphics': diff --git a/src/expressFlow.js b/src/expressFlow.js index 8beb89623b..6c993cc76c 100644 --- a/src/expressFlow.js +++ b/src/expressFlow.js @@ -64,7 +64,7 @@ async function checkAllowedEdits(image) { } // Apply edits via the real Adobe Express generate-variation pipeline. -async function editGraphic(phoneNumber, image, edits, { sendImage }) { +async function editGraphic(phoneNumber, image, edits, { sendImage, sendText }) { let elements; try { const doc = await expressApi.getTaggedDocument(image.docId); @@ -93,6 +93,8 @@ async function editGraphic(phoneNumber, image, edits, { sendImage }) { return `The maximum discount I can apply on "${image.name}" is ${MAX_DISCOUNT_PERCENT}%. Try again with ${MAX_DISCOUNT_PERCENT}% or less.`; } + if (typeof sendText === 'function') await sendText(phoneNumber, '⏳ Applying your edit and re-rendering with Adobe Express…'); + const mergedEdits = { ...image.currentEdits, ...edits }; const pages = expressApi.pagesForEdits(elements, Object.keys(mergedEdits)); const preferredDocumentName = expressApi.buildPreferredDocumentName(image.name); diff --git a/src/localFlow.js b/src/localFlow.js index e23e516164..6f3f30e739 100644 --- a/src/localFlow.js +++ b/src/localFlow.js @@ -15,10 +15,26 @@ function loadOnamDesign() { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Send progress ("streaming") messages one at a time with a pause between them, +// so generation feels like real work rather than an instant response. No-op when +// no sender is provided (keeps unit tests fast). Delay is tunable for the demo. +async function streamProgress(sendText, phoneNumber, messages) { + if (typeof sendText !== 'function') return; + const delay = Number(process.env.GEN_STEP_DELAY_MS ?? 1800); + for (const message of messages) { + await sendText(phoneNumber, message); + await sleep(delay); + } +} + // create_design: register a brand-new local design and send its image with a // friendly caption. If the user opted to include their address, the creative // that carries it is the "final" image (we only ship 2 canned URLs for this flow). -async function createDesign(phoneNumber, { occasion, products, offer, includeAddress } = {}, { sendImage }) { +async function createDesign(phoneNumber, { occasion, products, offer, includeAddress } = {}, { sendImage, sendText }) { const design = loadOnamDesign(); const productList = Array.isArray(products) && products.length ? products.join(' + ') : (products || 'your products'); const festive = occasion || 'Festive'; @@ -30,6 +46,16 @@ async function createDesign(phoneNumber, { occasion, products, offer, includeAdd console.log('[action:create_design]', { phoneNumber, occasion, products, offer, includeAddress, image: imageUrl }); + // Stream the "work being done" so generation feels real, then send the image. + const steps = [ + `🎨 Got it — creating your ${festive} creative now. Give me a few seconds…`, + `📦 Pulling Croma's logo and the approved ${festive} colour palette from the brand kit…`, + `📱 Adding your products: ${productList}…`, + `🏷️ Applying ${offer ? `your ${offer} offer` : 'your offer'} and festive ${festive} styling…`, + ]; + if (includeAddress) steps.push('📍 Placing your store address…'); + await streamProgress(sendText, phoneNumber, steps); + const offerText = offer ? ` at ${offer}` : ''; const addressText = includeAddress ? ', with your store address' : ''; const caption = `🌼 Happy ${festive}! Here's your festive creative — ${productList}${offerText}${addressText}. On-brand with Croma's logo and approved colours, ready to share. ✨`; @@ -106,7 +132,7 @@ function checkAllowedEdits(image) { } // Apply edits by resolving to the matching canned image URL. -async function editGraphic(phoneNumber, image, rawEdits, { sendImage }) { +async function editGraphic(phoneNumber, image, rawEdits, { sendImage, sendText }) { const design = image.design; const editableSlots = design.slots.editable; @@ -144,6 +170,12 @@ async function editGraphic(phoneNumber, image, rawEdits, { sendImage }) { const imageUrl = resolveLocalImage(design, currentEdits); console.log('[edit:local] resolved image', { imageId: image.id, currentEdits, imageUrl }); + // Stream progress so the re-render feels real, then send the updated image. + const progress = wantsMalayalam + ? ['🌸 Translating your banner to Malayalam…', '✍️ Re-rendering with the Malayalam text…'] + : ['✍️ Updating your creative…']; + await streamProgress(sendText, phoneNumber, progress); + const caption = wantsMalayalam ? '🌸 Here you go — your banner is now in Malayalam!' : `✅ Done! Updated ${Object.entries(appliedEdits).map(([key, value]) => `${key} → ${value}`).join(', ')}.`; diff --git a/src/localFlow.test.js b/src/localFlow.test.js index cccb7f42ee..306cbc5dc7 100644 --- a/src/localFlow.test.js +++ b/src/localFlow.test.js @@ -78,6 +78,41 @@ test('createDesign with includeAddress sends the with-address (final) image', as assert.match(sent[0].caption, /store address/); }); +test('createDesign streams progress messages (products + address) before the image', async () => { + useFixture(); + process.env.GEN_STEP_DELAY_MS = '0'; // no real pause in tests + const texts = []; + const sendText = async (_to, msg) => texts.push(msg); + const { sendImage, sent } = captureSendImage(); + + await localFlow.createDesign( + 'onam-stream-1', + { occasion: 'Onam', products: ['Samsung Galaxy S26', 'Galaxy Buds 3'], offer: '20% off', includeAddress: true }, + { sendImage, sendText } + ); + + assert.ok(texts.length >= 4, 'streams several progress messages'); + assert.ok(texts.some((m) => /Samsung Galaxy S26 \+ Galaxy Buds 3/.test(m)), 'names the products'); + assert.ok(texts.some((m) => /store address/i.test(m)), 'mentions the address step'); + assert.equal(sent[0].link, FIXTURE.images.final); // image sent after the stream +}); + +test('editGraphic streams Malayalam progress before sending the Malayalam image', async () => { + process.env.GEN_STEP_DELAY_MS = '0'; + const { image } = await setup('onam-stream-2'); + const texts = []; + const sent = []; + const sendText = async (_to, msg) => texts.push(msg); + + await localFlow.editGraphic('onam-stream-2', image, { language: 'Malayalam' }, { + sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }), + sendText, + }); + + assert.ok(texts.some((m) => /Malayalam/i.test(m)), 'streams a Malayalam progress message'); + assert.equal(sent.at(-1).link, FIXTURE.images.malayalam); +}); + test('checkAllowedEdits lists the editable slots from the static schema', async () => { const { image } = await setup('onam-phone-check'); From 2fd4cf3d6678a45227ae93c7f99170fbb1343e39 Mon Sep 17 00:00:00 2001 From: varun kalra Date: Wed, 22 Jul 2026 18:52:39 +0530 Subject: [PATCH 28/35] fix(local): put the follow-up in the image caption so it can't precede the image WhatsApp delivers link images a beat after plain text, so a follow-up sent as a separate message after the image ("Want to change anything?" / "Anything else?") was showing up before the image. - fold the follow-up prompt into the image caption for create and edit success paths; the flow sends the image+caption and returns { skipSend, historyText } so app.js no longer sends a separate (racing) text - app.js create_design/edit_graphic handle the string-vs-object return like check_allowed_edits already does; guardrail rejections still return a string - apply the same caption treatment to the express edit path (latent race) - tests updated for the new return shape / caption assertions Co-Authored-By: Claude Opus 4.8 (1M context) --- src/actions.test.js | 2 +- src/app.js | 26 +++++++++++++++++++++----- src/expressFlow.js | 6 ++++-- src/expressFlow.test.js | 2 +- src/localFlow.js | 12 ++++++++---- src/localFlow.test.js | 11 ++++++----- 6 files changed, 41 insertions(+), 18 deletions(-) diff --git a/src/actions.test.js b/src/actions.test.js index 26aef77d04..a0d2d6b2b6 100644 --- a/src/actions.test.js +++ b/src/actions.test.js @@ -49,6 +49,6 @@ test('routes a local-source image to the canned flow (no Express API call)', asy const reply = await actionEditGraphic('phone-r3', 'local_1', { address: 'MG Road' }, { sendImage }); - assert.match(reply, /anything else/i); + assert.match(reply.historyText, /anything else/i); assert.equal(sent.at(-1), 'https://cdn.test/f.png'); // canned "final" URL — proves the local flow ran }); diff --git a/src/app.js b/src/app.js index 7bc7806901..b6f246dfa2 100644 --- a/src/app.js +++ b/src/app.js @@ -324,10 +324,18 @@ app.post('/', async (req, res) => { replyText = await actionListCampaignGraphics(); break; - case 'create_design': + case 'create_design': { // Progress is streamed from inside the flow (with the product/offer context). - replyText = await actionCreateDesign(phoneNumber, args, { sendImage, sendText }); + // On success the flow already sent the image+caption, so skip the extra text. + const result = await actionCreateDesign(phoneNumber, args, { sendImage, sendText }); + if (typeof result === 'string') { + replyText = result; + } else { + replyText = result.historyText; + skipSend = true; + } break; + } case 'ask_for_more_information': console.log('[action:ask_for_more_information]', { question: args.question, options: args.options }); @@ -361,10 +369,18 @@ app.post('/', async (req, res) => { break; } - case 'edit_graphic': - // Progress is streamed from inside the flow. - replyText = await actionEditGraphic(phoneNumber, args.image_id, args.edits, { sendImage, sendText }); + case 'edit_graphic': { + // Progress is streamed from inside the flow. On success the flow already + // sent the image+caption; a guardrail rejection returns a plain string. + const result = await actionEditGraphic(phoneNumber, args.image_id, args.edits, { sendImage, sendText }); + if (typeof result === 'string') { + replyText = result; + } else { + replyText = result.historyText; + skipSend = true; + } break; + } case 'generate_bulk_graphics': await sendText(phoneNumber, '⏳ Generating graphics from your file, this may take a moment...'); diff --git a/src/expressFlow.js b/src/expressFlow.js index 6c993cc76c..10e4a24ccd 100644 --- a/src/expressFlow.js +++ b/src/expressFlow.js @@ -113,15 +113,17 @@ async function editGraphic(phoneNumber, image, edits, { sendImage, sendText }) { recordEdits(phoneNumber, image.id, edits); const summary = Object.entries(edits).map(([key, value]) => `• ${key}: ${value}`).join('\n'); + // Caption carries the summary so text never arrives before the image. + const caption = `Updated "${image.name}":\n${summary}\n\nAnything else you'd like to change?`; try { - await sendImage(phoneNumber, thumbnailUrl); + await sendImage(phoneNumber, thumbnailUrl, caption); } catch (err) { console.error('[expressFlow.editGraphic] sendImage error', { docId: image.docId, message: err.message }); return `Updated "${image.name}", but I couldn't send the image right now — try asking me to resend it.`; } - return `Updated "${image.name}":\n${summary}`; + return { skipSend: true, historyText: caption }; } module.exports = { selectTvModel, checkAllowedEdits, editGraphic, MAX_DISCOUNT_PERCENT }; diff --git a/src/expressFlow.test.js b/src/expressFlow.test.js index 0276db81a8..26e091b573 100644 --- a/src/expressFlow.test.js +++ b/src/expressFlow.test.js @@ -105,7 +105,7 @@ test('editGraphic applies an allowed edit end-to-end: generates, polls, sends th const reply = await expressFlow.editGraphic('phone-5', image, { cta: '20% off' }, { sendImage }); - assert.match(reply, /Updated "Croma Earbuds"/); + assert.match(reply.historyText, /Updated "Croma Earbuds"/); assert.equal(sentCalls.length, 1); assert.equal(sentCalls[0].to, 'phone-5'); assert.equal(sentCalls[0].link, 'https://example.com/thumb.png'); diff --git a/src/localFlow.js b/src/localFlow.js index 6f3f30e739..afedeebf3a 100644 --- a/src/localFlow.js +++ b/src/localFlow.js @@ -58,7 +58,9 @@ async function createDesign(phoneNumber, { occasion, products, offer, includeAdd const offerText = offer ? ` at ${offer}` : ''; const addressText = includeAddress ? ', with your store address' : ''; - const caption = `🌼 Happy ${festive}! Here's your festive creative — ${productList}${offerText}${addressText}. On-brand with Croma's logo and approved colours, ready to share. ✨`; + // The follow-up prompt lives inside the caption so it never arrives before the + // image — WhatsApp delivers link images a beat after plain text. + const caption = `🌼 Happy ${festive}! Here's your festive creative — ${productList}${offerText}${addressText}. On-brand with Croma's logo and approved colours, ready to share. ✨\n\nWant to change anything? For example, I can translate the whole banner to Malayalam. 🌸`; try { await sendImage(phoneNumber, imageUrl, caption); @@ -67,7 +69,7 @@ async function createDesign(phoneNumber, { occasion, products, offer, includeAdd return `I built your ${festive} design, but couldn't send the image right now — try asking me to resend it.`; } - return 'Want to change anything? For example, I can translate the whole banner to Malayalam. 🌸'; + return { skipSend: true, historyText: caption }; } function normalizeKey(key) { @@ -176,9 +178,11 @@ async function editGraphic(phoneNumber, image, rawEdits, { sendImage, sendText } : ['✍️ Updating your creative…']; await streamProgress(sendText, phoneNumber, progress); - const caption = wantsMalayalam + const summary = wantsMalayalam ? '🌸 Here you go — your banner is now in Malayalam!' : `✅ Done! Updated ${Object.entries(appliedEdits).map(([key, value]) => `${key} → ${value}`).join(', ')}.`; + // Follow-up lives in the caption so it can't arrive before the image. + const caption = `${summary}\n\nAnything else you'd like to change?`; try { await sendImage(phoneNumber, imageUrl, caption); @@ -187,7 +191,7 @@ async function editGraphic(phoneNumber, image, rawEdits, { sendImage, sendText } return `I updated "${image.name}", but couldn't send the image right now — try asking me to resend it.`; } - return "Anything else you'd like to change?"; + return { skipSend: true, historyText: caption }; } module.exports = { createDesign, checkAllowedEdits, editGraphic }; diff --git a/src/localFlow.test.js b/src/localFlow.test.js index 306cbc5dc7..ffbf2be065 100644 --- a/src/localFlow.test.js +++ b/src/localFlow.test.js @@ -61,7 +61,8 @@ test('createDesign registers a local design and sends the base image with a capt assert.equal(sent[0].link, FIXTURE.images.base); assert.match(sent[0].caption, /Onam/); assert.match(sent[0].caption, /20% off/); - assert.match(reply, /change anything/i); + assert.match(sent[0].caption, /change anything/i); // follow-up is in the caption, not a separate text + assert.equal(reply.skipSend, true); }); test('createDesign with includeAddress sends the with-address (final) image', async () => { @@ -161,10 +162,10 @@ test('adding only a store address resolves to the final image (demo msg 2)', asy test('a translate request under an unrecognized key still resolves to Malayalam (demo msg 3)', async () => { const { image, sent } = await setup('onam-phone-b'); - const reply = await localFlow.editGraphic('onam-phone-b', image, { language: 'Malayalam' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); + await localFlow.editGraphic('onam-phone-b', image, { language: 'Malayalam' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); + // resolving to the Malayalam image (rather than a "locked by HQ" text) proves it wasn't rejected assert.equal(sent.at(-1).link, FIXTURE.images.malayalam); - assert.doesNotMatch(reply, /locked by HQ/); }); test('a Malayalam value under any key resolves to Malayalam', async () => { @@ -178,10 +179,10 @@ test('a Malayalam value under any key resolves to Malayalam', async () => { test('a Malayalam value landing on the background key translates, not palette-rejected (regression)', async () => { const { image, sent } = await setup('onam-phone-d'); - const reply = await localFlow.editGraphic('onam-phone-d', image, { background: 'ഓണം' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); + await localFlow.editGraphic('onam-phone-d', image, { background: 'ഓണം' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); + // resolving to the Malayalam image proves it wasn't palette-rejected assert.equal(sent.at(-1).link, FIXTURE.images.malayalam); - assert.doesNotMatch(reply, /approved palette/); }); test('editing a locked element is refused', async () => { From ff7326ed116cc8194d7c4435daa4ea4007748bb6 Mon Sep 17 00:00:00 2001 From: Priyank Modi Date: Thu, 23 Jul 2026 00:04:55 +0530 Subject: [PATCH 29/35] Add design doc for Edit Product/Discount/Price menu with Hinglish-mirrored replies Specifies the fixed 3-button menu, WhatsApp list picker, generalized discount-cap validation, and GPT-phrased language-mirrored responses requested for the Express-catalog edit flow. --- ...product-discount-price-edit-flow-design.md | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-23-product-discount-price-edit-flow-design.md diff --git a/docs/superpowers/specs/2026-07-23-product-discount-price-edit-flow-design.md b/docs/superpowers/specs/2026-07-23-product-discount-price-edit-flow-design.md new file mode 100644 index 0000000000..628b33502d --- /dev/null +++ b/docs/superpowers/specs/2026-07-23-product-discount-price-edit-flow-design.md @@ -0,0 +1,196 @@ +# Design: Fixed Edit Product/Discount/Price menu with Hinglish-mirrored replies + +## Goal + +Support this WhatsApp conversation flow for Express-catalog graphics (e.g. the Croma/TV template): + +1. User sends an edit message ("I want to edit this" / similar). +2. Bot shows "What would you like to change?" with 3 quick-reply buttons: **Edit Product**, **Edit Discount**, **Edit Price**. +3. User taps **Edit Product**. +4. Bot shows a WhatsApp **list** message: "Which product do you want?" with a "Choose product" button and the 3 TV models as rows. +5. User picks a model (e.g. Sony Bravia K-75). +6. Bot calls the Express API to set `productImage`, `oldPrice` (33999), `price` (27199) in one shot. +7. Bot sends the updated image, a phrased confirmation ("I have updated product, with price & discount"), and the 3-button menu again. +8. User asks for a discount in Hinglish ("discount ko 50% kar do"). +9. Bot computes the implied price, finds it exceeds the 40% cap, denies in Hinglish ("Iss product pr maximum 40% discount de sakte hain"), and shows the 3-button menu again. +10. User accepts the cap ("Theek h, 40% hi kar do"). +11. Bot computes price = 33999 × 0.6 = 20399, applies it, sends the updated image, a phrased Hinglish confirmation ("Maine discount aur price updated kar diya hai"), and the 3-button menu again. + +Scope: this replaces `check_allowed_edits`'s behavior **only for `source: 'express'` graphics** (the Express-catalog templates, e.g. the TV/Croma one). Local/Onam-style `source: 'local'` designs (background/address/headline edits — no product or price concept) are untouched and keep today's dynamic field-list menu. Editable field names on the real document are expected to be `productImage`, `oldPrice`, `price` as used today, though exact tag names may vary slightly later — see "Field name matching" below. + +## 1. Fixed top-level menu (reuses existing bare-field id scheme) + +`actionCheckAllowedEdits` branches on `image.source`: +- `'local'` → unchanged (today's dynamic field list from `localEditElements`). +- `'express'` → always returns 3 fixed options, using the existing bare-field id scheme from `interactiveReply.js` (`edit::`, **no changes needed to that module**): + +```js +const TOP_LEVEL_EDIT_FIELDS = [ + { fieldName: 'product', title: 'Edit Product' }, + { fieldName: 'discount', title: 'Edit Discount' }, + { fieldName: 'price', title: 'Edit Price' }, +]; +``` + +`bodyText`: `"What would you like to change?"` (fixed, English, never phrased by GPT — button/menu copy stays constant regardless of conversation language). + +Tapping a button produces the same kind of synthetic message as today, e.g. `"I'd like to change \"discount\" on image img_1."`, fed back through `decideAction`. + +### New system-prompt rules for these three bare fields + +- `"product"` → call `select_tv_model` (same tool used today for free-text "change product to tv"; only TV category exists, so no ambiguity). +- `"discount"` with no value yet, or `"price"` with no value yet → `ask_for_more_information` (existing bare-field behavior, unchanged). +- `"discount"` **with a value** (e.g. "50%", "50 percent", "discount ko 50% kar do") → GPT computes `newPrice = round(oldPrice × (1 − discountPercent/100))` using the `oldPrice` shown in the images list (see below), then calls `edit_graphic` with `{ price: newPrice }` only (never touches `oldPrice`). +- `"price"` **with a value** → call `edit_graphic` with `{ price: }` directly, no computation. + +## 2. Images list gains current field values + +`decideAction`'s system prompt `imagesList` is extended from `- : ` to include known current values pulled from `image.currentEdits` (already tracked in `imageStore`, no new storage): + +``` +- img_1: Croma Diwali offer (oldPrice: 33999, price: 27199) +``` + +This is what lets GPT compute a discount-derived price without an extra round trip to ask the user "what's the current price?". + +## 3. Product picker becomes a real WhatsApp list message + +`actionSelectTvModel` gains a `buttonText` field: + +```js +function actionSelectTvModel(imageId) { + return { + type: 'edit_options', + bodyText: 'Which product do you want?', + buttonText: 'Choose product', + options: TV_MODEL_TITLES.map((title) => ({ id: buildValueEditId(imageId, TV_MODEL_EDITS), title })), + }; +} +``` + +New `sendList(to, { bodyText, buttonText, options })` in `app.js` builds a native list message: + +```js +function sendList(to, { bodyText, buttonText, options }) { + return whatsappPost({ + messaging_product: 'whatsapp', + to, + type: 'interactive', + interactive: { + type: 'list', + body: { text: bodyText }, + action: { + button: buttonText, + sections: [{ rows: options.map((o) => ({ id: o.id, title: o.title })) }], + }, + }, + }); +} +``` + +`sendEditOptions` picks list-vs-buttons based on whether `buttonText` is present on the result: + +```js +async function sendEditOptions(to, result) { + if (result.options.length === 0) return sendText(to, result.bodyText); + if (result.buttonText) return sendList(to, result); + // ...existing button-chunking loop, unchanged +} +``` + +The fixed 3-button top-level menu (part 1) has no `buttonText`, so it keeps using buttons as today — only the product picker becomes a list. Model titles stay as they are today: Sony Bravia K-75 / LG UA82 AI / Samsung UA4. LG and Samsung continue to share Sony's placeholder image/price (existing, intentional scoping from the prior TV-swap design — not changed here). + +Row `id`s reuse `buildValueEditId` exactly as today, so tapping a list row parses identically to tapping a button (`message?.interactive?.list_reply` is already read defensively in `app.js`). + +## 4. Generalized 40% discount cap (server-enforced, not GPT-trusted) + +Today's cap (`MAX_DISCOUNT_PERCENT = 40`, `editExpressDesign`) only fires on a literal field matching `/discount/i`. Since GPT now computes and submits a plain `price`, this needs to generalize: whenever the edits (merged with `image.currentEdits`) contain both an "old price" field and a "new price" field, compute the implied discount and reject if it exceeds the cap — regardless of whether the user phrased it as a discount % or a direct price. + +### Field name matching + +To tolerate minor future naming variation (per your note), matching is regex-based rather than exact-string: +- Reference/original price: `/^old.?price$/i` (matches `oldPrice`, `old_price`). +- New/selling price: `/(^|_)price$/i`, excluding whatever matched as the reference price above. + +```js +function findOldPriceKey(keys) { + return keys.find((k) => /^old.?price$/i.test(k)); +} +function findNewPriceKey(keys, oldPriceKey) { + return keys.find((k) => k !== oldPriceKey && /(^|_)price$/i.test(k)); +} +``` + +In `editExpressDesign`, after merging `edits` into `image.currentEdits`: + +```js +const mergedEdits = { ...image.currentEdits, ...edits }; +const oldPriceKey = findOldPriceKey(Object.keys(mergedEdits)); +const newPriceKey = findNewPriceKey(Object.keys(mergedEdits), oldPriceKey); +if (oldPriceKey && newPriceKey && requestedKeys.includes(newPriceKey)) { + const oldPrice = Number(mergedEdits[oldPriceKey]); + const newPrice = Number(mergedEdits[newPriceKey]); + const impliedDiscountPercent = ((oldPrice - newPrice) / oldPrice) * 100; + if (impliedDiscountPercent > MAX_DISCOUNT_PERCENT) { + return { status: 'discount_capped', productName: image.name, maxPercent: MAX_DISCOUNT_PERCENT }; + } +} +``` + +This replaces the existing `oversizedDiscountKeys` literal-field check (kept as a fallback for any future literal `discount_text`-style field, unchanged logic there). + +Boundary case: exactly 40% is allowed (matches the script: 33999 × 0.6 = 20399.4 → rounds to 20399, exactly a 40.0% discount, and step 11 shows this succeeding). + +## 5. Structured outcomes + dynamic, language-mirrored replies + +`actionEditGraphic` (Express path) and `actionSelectTvModel`'s eventual `edit_graphic` call no longer return final hardcoded strings. They return a structured outcome: + +```js +{ status: 'success', productName, changes: { productImage, oldPrice, price, ... } } +{ status: 'discount_capped', productName, maxPercent: 40 } +{ status: 'disallowed_fields', productName, disallowedKeys, allowedSummary } +{ status: 'error', productName, reason } +``` + +New `phraseOutcome(phoneNumber, userMessage, outcome)` in `app.js`: a second, tool-free OpenAI chat completion call that turns the structured outcome into the actual WhatsApp reply text, matching the user's language style: + +> System prompt: "You are a WhatsApp assistant. Given this outcome, write a short reply to the user. Match the user's language and style — if their message was Hinglish (romanized Hindi mixed with English), reply in Hinglish; otherwise reply in English. Don't invent facts beyond the outcome given. +> +> Examples of the tone/style to match: +> - Success (English): 'I have updated product, with price & discount' +> - Success (Hinglish): 'Maine discount aur price updated kar diya hai' +> - Capped (Hinglish): 'Iss product pr maximum 40% discount de sakte hain'" +> +> User message: `` +> Outcome: `` + +Its `content` becomes the `sendText` reply. This applies **only** to `edit_graphic`/`select_tv_model` outcomes on Express-catalog graphics — menu prompts and button/row titles stay fixed English strings always (never phrased by GPT), matching the script where the 3-button menu reappears in English even mid-Hinglish conversation. + +`phraseOutcome` isn't unit-tested for exact wording (it's an LLM call); the *outcome data* feeding into it is fully unit-tested instead. Manual/simulated webhook checks verify the reply is sensible and in the right language register. + +## 6. Post-edit menu resurfacing + +For Express-catalog graphics, every `edit_graphic`/`select_tv_model` outcome — success or capped/rejected — is followed by resending the fixed 3-button menu. Dispatch sequence in `app.js` for these actions: + +1. Run the action → get structured outcome. +2. If `status === 'success'`, `sendImage` the updated thumbnail. +3. `phraseOutcome(...)` → `sendText` the phrased reply. +4. `sendButtons(phoneNumber, 'What would you like to change?', TOP_LEVEL_EDIT_OPTIONS)` — always, regardless of outcome, for Express-catalog graphics only. + +Local/Onam designs keep their current single-reply behavior (no forced menu resend) — this step only fires when `image.source === 'express'`. + +## Testing + +- Unit tests for `findOldPriceKey`/`findNewPriceKey` and the generalized cap check: under cap, exactly at cap (40%, passes), over cap (rejected), missing old/new price keys (no-op, falls through to existing behavior). +- Unit tests for `sendList` payload shape (list type, button text, row ids/titles). +- Unit tests for `actionCheckAllowedEdits` on `source: 'express'` images returning the 3 fixed bare-field options; `source: 'local'` images unaffected. +- Unit tests for `actionSelectTvModel` including the new `buttonText` field. +- Existing `interactiveReply.test.js` coverage is unchanged (no new id scheme introduced). +- Manual/simulated webhook run through the full script: edit → product list → pick Sony → confirmation + menu → Hinglish discount request → capped denial + menu → accept 40% → success + menu. + +## Out of scope + +- Per-model distinct images/pricing for LG/Samsung (still shared placeholder, per existing TV-swap design). +- Any product category other than TV. +- Exact-wording assertions on GPT-phrased replies. +- Changes to `list_campaign_graphics`, `create_design`, or `generate_bulk_graphics` — untouched. From ac280c02a01f3129fef4decb234029f9725fc3b3 Mon Sep 17 00:00:00 2001 From: Priyank Modi Date: Thu, 23 Jul 2026 00:16:30 +0530 Subject: [PATCH 30/35] Generalize discount cap to price edits and return structured edit outcomes editExpressDesign now rejects any price edit implying more than 40% off (not just literal "discount" fields), with rounding tolerance for whole-rupee prices, and returns structured outcome objects instead of hardcoded reply strings so the caller can phrase the final reply. --- src/actions.js | 51 ++++++++++++++++++++++++------ src/actions.test.js | 75 +++++++++++++++++++++++++++++++++++++++------ 2 files changed, 106 insertions(+), 20 deletions(-) diff --git a/src/actions.js b/src/actions.js index 42ffa98ef4..e56e275d0c 100644 --- a/src/actions.js +++ b/src/actions.js @@ -62,6 +62,33 @@ function parsePercent(value) { return match ? Number(match[1]) : null; } +const ROUNDING_TOLERANCE_PERCENT = 0.5; + +function findOldPriceKey(keys) { + return keys.find((key) => /^old.?price$/i.test(key)); +} + +function findNewPriceKey(keys, oldPriceKey) { + return keys.find((key) => key !== oldPriceKey && /(^|_)price$/i.test(key)); +} + +// Covers both "discount ko 50% kar do" (GPT computes a price from oldPrice) and a +// direct "set price to X" request — either way, a price drop of more than the cap +// (plus rounding slack for whole-rupee prices) is rejected. +function impliesExcessiveDiscount(mergedEdits, requestedKeys) { + const keys = Object.keys(mergedEdits); + const oldPriceKey = findOldPriceKey(keys); + const newPriceKey = findNewPriceKey(keys, oldPriceKey); + if (!oldPriceKey || !newPriceKey || !requestedKeys.includes(newPriceKey)) return false; + + const oldPrice = Number(mergedEdits[oldPriceKey]); + const newPrice = Number(mergedEdits[newPriceKey]); + if (!Number.isFinite(oldPrice) || oldPrice <= 0 || !Number.isFinite(newPrice)) return false; + + const impliedDiscountPercent = ((oldPrice - newPrice) / oldPrice) * 100; + return impliedDiscountPercent > MAX_DISCOUNT_PERCENT + ROUNDING_TOLERANCE_PERCENT; +} + // ── Local (canned-image) design helpers — Flow 2.2 ─────────────────────────── function normalizeKey(key) { @@ -208,7 +235,7 @@ async function editExpressDesign(phoneNumber, image, edits, { sendImage }) { elements = expressApi.collectTaggedElements(doc); } catch (err) { console.error('[editExpressDesign] Express API error', { docId: image.docId, message: err.message }); - return `Sorry, I couldn't reach Adobe Express to apply that edit. Please try again in a moment.`; + return { status: 'api_error', productName: image.name, reason: 'lookup_failed' }; } const allowedNames = elements.map((element) => element.name); @@ -217,7 +244,12 @@ async function editExpressDesign(phoneNumber, image, edits, { sendImage }) { if (disallowedKeys.length > 0) { const elementsWithCurrentEdits = withCurrentEdits(elements, image.currentEdits); - return `I can't edit ${disallowedKeys.join(', ')} on "${image.name}". ${expressApi.formatAllowedEdits(image.name, elementsWithCurrentEdits)}`; + return { + status: 'disallowed_fields', + productName: image.name, + disallowedKeys, + allowedSummary: expressApi.formatAllowedEdits(image.name, elementsWithCurrentEdits), + }; } const oversizedDiscountKeys = requestedKeys.filter((key) => { @@ -226,11 +258,12 @@ async function editExpressDesign(phoneNumber, image, edits, { sendImage }) { return percent !== null && percent > MAX_DISCOUNT_PERCENT; }); - if (oversizedDiscountKeys.length > 0) { - return `The maximum discount I can apply on "${image.name}" is ${MAX_DISCOUNT_PERCENT}%. Try again with ${MAX_DISCOUNT_PERCENT}% or less.`; + const mergedEdits = { ...image.currentEdits, ...edits }; + + if (oversizedDiscountKeys.length > 0 || impliesExcessiveDiscount(mergedEdits, requestedKeys)) { + return { status: 'discount_capped', productName: image.name, maxPercent: MAX_DISCOUNT_PERCENT }; } - const mergedEdits = { ...image.currentEdits, ...edits }; const pages = expressApi.pagesForEdits(elements, Object.keys(mergedEdits)); const preferredDocumentName = expressApi.buildPreferredDocumentName(image.name); @@ -242,21 +275,19 @@ async function editExpressDesign(phoneNumber, image, edits, { sendImage }) { console.log('[edit:express] resolved image', { imageId: image.id, docId: image.docId, thumbnailUrl }); } catch (err) { console.error('[editExpressDesign] generate/poll error', { docId: image.docId, message: err.message }); - return `Sorry, something went wrong generating your updated "${image.name}". Please try again.`; + return { status: 'api_error', productName: image.name, reason: 'generate_failed' }; } recordEdits(phoneNumber, image.id, edits); - const summary = Object.entries(edits).map(([key, value]) => `• ${key}: ${value}`).join('\n'); - try { await sendImage(phoneNumber, thumbnailUrl); } catch (err) { console.error('[editExpressDesign] sendImage error', { docId: image.docId, message: err.message }); - return `Updated "${image.name}", but I couldn't send the image right now — try asking me to resend it.`; + return { status: 'delivery_failed', productName: image.name, changes: edits }; } - return `Updated "${image.name}":\n${summary}`; + return { status: 'success', productName: image.name, changes: edits }; } async function actionGenerateBulkGraphics(filename) { diff --git a/src/actions.test.js b/src/actions.test.js index 43e481e204..d97c45b14b 100644 --- a/src/actions.test.js +++ b/src/actions.test.js @@ -71,7 +71,7 @@ test('actionCheckAllowedEdits returns a friendly message when the Express API ca assert.match(reply, /couldn't check the allowed edits/); }); -test('actionEditGraphic rejects edits outside the tagged elements and makes no generate call', async () => { +test('actionEditGraphic returns a disallowed_fields status and makes no generate call for a field outside the tagged elements', async () => { writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; expressApi.generateVariation = async () => { @@ -80,9 +80,12 @@ test('actionEditGraphic rejects edits outside the tagged elements and makes no g let sendImageCalled = false; const sendImage = async () => { sendImageCalled = true; }; - const reply = await actionEditGraphic('phone-4', 'img_1', { background_color: 'red' }, { sendImage }); + const result = await actionEditGraphic('phone-4', 'img_1', { background_color: 'red' }, { sendImage }); - assert.match(reply, /can't edit background_color/); + assert.equal(result.status, 'disallowed_fields'); + assert.equal(result.productName, 'Croma Earbuds'); + assert.deepEqual(result.disallowedKeys, ['background_color']); + assert.match(result.allowedSummary, /Edits allowed on "Croma Earbuds"/); assert.equal(sendImageCalled, false); }); @@ -104,9 +107,9 @@ test('actionEditGraphic applies an allowed edit end-to-end: generates, polls, se const sentCalls = []; const sendImage = async (to, link) => { sentCalls.push({ to, link }); }; - const reply = await actionEditGraphic('phone-5', 'img_1', { cta: '20% off' }, { sendImage }); + const result = await actionEditGraphic('phone-5', 'img_1', { cta: '20% off' }, { sendImage }); - assert.match(reply, /Updated "Croma Earbuds"/); + assert.deepEqual(result, { status: 'success', productName: 'Croma Earbuds', changes: { cta: '20% off' } }); assert.equal(sentCalls.length, 1); assert.equal(sentCalls[0].to, 'phone-5'); assert.equal(sentCalls[0].link, 'https://example.com/thumb.png'); @@ -123,9 +126,10 @@ test('actionEditGraphic returns a friendly message and does not record the edit let sendImageCalled = false; const sendImage = async () => { sendImageCalled = true; }; - const reply = await actionEditGraphic('phone-6', 'img_1', { cta: '20% off' }, { sendImage }); + const result = await actionEditGraphic('phone-6', 'img_1', { cta: '20% off' }, { sendImage }); - assert.match(reply, /something went wrong generating/); + assert.equal(result.status, 'api_error'); + assert.equal(result.reason, 'generate_failed'); assert.equal(sendImageCalled, false); const image = findTrackedImage('phone-6', 'img_1'); @@ -140,15 +144,66 @@ test('actionEditGraphic tells the user delivery failed but keeps the recorded ed const sendImage = async () => { throw new Error('WhatsApp could not fetch the link'); }; - const reply = await actionEditGraphic('phone-7', 'img_1', { cta: '20% off' }, { sendImage }); + const result = await actionEditGraphic('phone-7', 'img_1', { cta: '20% off' }, { sendImage }); - assert.match(reply, /couldn't send the image right now/); - assert.doesNotMatch(reply, /something went wrong generating/); + assert.equal(result.status, 'delivery_failed'); + assert.deepEqual(result.changes, { cta: '20% off' }); const image = findTrackedImage('phone-7', 'img_1'); assert.deepEqual(image.currentEdits, { cta: '20% off' }); }); +test('actionEditGraphic returns an api_error/lookup_failed status when getTaggedDocument fails', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async () => { throw new Error('getTaggedDocument failed 500: boom'); }; + + const result = await actionEditGraphic('phone-8', 'img_1', { cta: '20% off' }, { sendImage: async () => {} }); + + assert.equal(result.status, 'api_error'); + assert.equal(result.reason, 'lookup_failed'); +}); + +const TV_ELEMENTS_DOC = { + documentPages: [ + { + pageNumber: 1, + taggedElements: [ + { name: 'productImage', type: 'image', value: '' }, + { name: 'oldPrice', type: 'text', value: '' }, + { name: 'price', type: 'text', value: '' }, + ], + }, + ], +}; + +test('actionEditGraphic rejects a price edit implying more than 40% off and makes no generate call', async () => { + writeFixtureCatalog([{ id: 'img_2', name: 'TV Product', docId: 'urn:doc:2' }]); + expressApi.getTaggedDocument = async () => TV_ELEMENTS_DOC; + expressApi.generateVariation = async () => { throw new Error('should not be called'); }; + recordEdits('phone-9', 'img_2', { productImage: 'https://example.com/tv.png', oldPrice: 33999, price: 27199 }); + + const result = await actionEditGraphic('phone-9', 'img_2', { price: 17000 }, { sendImage: async () => {} }); + + assert.equal(result.status, 'discount_capped'); + assert.equal(result.maxPercent, 40); +}); + +test('actionEditGraphic applies a price edit at exactly the 40% cap (within rounding tolerance)', async () => { + writeFixtureCatalog([{ id: 'img_2', name: 'TV Product', docId: 'urn:doc:2' }]); + expressApi.getTaggedDocument = async () => TV_ELEMENTS_DOC; + expressApi.generateVariation = async (docId, tagMappings) => { + assert.deepEqual(tagMappings, { productImage: 'https://example.com/tv.png', oldPrice: 33999, price: 20399 }); + return { jobId: 'job-2', statusUrl: 'https://express-api.adobe.io/status/job-2' }; + }; + expressApi.pollJobStatus = async () => ({ status: 'succeeded', document: { thumbnailUrl: 'https://example.com/thumb2.png' } }); + recordEdits('phone-10', 'img_2', { productImage: 'https://example.com/tv.png', oldPrice: 33999, price: 27199 }); + + const result = await actionEditGraphic('phone-10', 'img_2', { price: 20399 }, { sendImage: async () => {} }); + + assert.equal(result.status, 'success'); + assert.deepEqual(result.changes, { price: 20399 }); +}); + test('actionSelectTvModel returns the 3 fixed TV model options with the question body text', () => { const result = actionSelectTvModel('img_1'); From 3bad80a387fa5869e271b77296e0a2eb2e2051bf Mon Sep 17 00:00:00 2001 From: Priyank Modi Date: Thu, 23 Jul 2026 00:17:40 +0530 Subject: [PATCH 31/35] Replace dynamic edit menu with fixed Edit Product/Discount/Price for Express-catalog images Express-catalog graphics now always show the same 3-option menu instead of a per-document field list, so choosing what to edit no longer needs an Express API round trip. Local/Onam-style designs are unaffected. --- src/actions.js | 50 +++++++++++++++++++++++++-------------------- src/actions.test.js | 50 +++++++++++++++++++-------------------------- 2 files changed, 49 insertions(+), 51 deletions(-) diff --git a/src/actions.js b/src/actions.js index e56e275d0c..d20317ffc3 100644 --- a/src/actions.js +++ b/src/actions.js @@ -168,6 +168,24 @@ async function editLocalDesign(phoneNumber, image, rawEdits, { sendImage }) { return `Updated "${image.name}":\n${summary}`; } +const TOP_LEVEL_EDIT_FIELDS = [ + { fieldName: 'product', title: 'Edit Product' }, + { fieldName: 'discount', title: 'Edit Discount' }, + { fieldName: 'price', title: 'Edit Price' }, +]; + +function buildTopLevelEditOptions(imageId) { + return { + type: 'edit_options', + bodyText: 'What would you like to change?', + options: TOP_LEVEL_EDIT_FIELDS.map(({ fieldName, title }) => ({ + id: `edit:${imageId}:${fieldName}`, + title, + })), + historyText: 'What would you like to change? (Edit Product / Edit Discount / Edit Price)', + }; +} + async function actionCheckAllowedEdits(phoneNumber, imageId) { const image = findTrackedImage(phoneNumber, imageId); console.log('[action:check_allowed_edits]', { phoneNumber, imageId, source: image?.source ?? 'not_found' }); @@ -175,30 +193,17 @@ async function actionCheckAllowedEdits(phoneNumber, imageId) { return formatUnknownImageMessage(phoneNumber); } - if (image.source === 'local') { - const elements = localEditElements(image); - return { - type: 'edit_options', - bodyText: 'What would you like to change?', - options: expressApi.buildEditOptions(elements, imageId), - historyText: expressApi.formatAllowedEdits(image.name, elements), - }; + if (image.source === 'express') { + return buildTopLevelEditOptions(imageId); } - try { - const doc = await expressApi.getTaggedDocument(image.docId); - const elements = expressApi.collectTaggedElements(doc); - const elementsWithCurrentEdits = withCurrentEdits(elements, image.currentEdits); - return { - type: 'edit_options', - bodyText: 'What would you like to change?', - options: expressApi.buildEditOptions(elementsWithCurrentEdits, imageId), - historyText: expressApi.formatAllowedEdits(image.name, elementsWithCurrentEdits), - }; - } catch (err) { - console.error('[actionCheckAllowedEdits] Express API error', { docId: image.docId, message: err.message }); - return `Sorry, I couldn't check the allowed edits for "${image.name}" right now. Please try again in a moment.`; - } + const elements = localEditElements(image); + return { + type: 'edit_options', + bodyText: 'What would you like to change?', + options: expressApi.buildEditOptions(elements, imageId), + historyText: expressApi.formatAllowedEdits(image.name, elements), + }; } function actionSelectTvModel(imageId) { @@ -303,4 +308,5 @@ module.exports = { actionEditGraphic, actionGenerateBulkGraphics, actionSelectTvModel, + buildTopLevelEditOptions, }; diff --git a/src/actions.test.js b/src/actions.test.js index d97c45b14b..bf62348047 100644 --- a/src/actions.test.js +++ b/src/actions.test.js @@ -3,7 +3,7 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); -const { actionCheckAllowedEdits, actionEditGraphic, actionSelectTvModel } = require('./actions'); +const { actionCheckAllowedEdits, actionEditGraphic, actionSelectTvModel, buildTopLevelEditOptions } = require('./actions'); const expressApi = require('./express/expressApi'); const { findTrackedImage, recordEdits } = require('./imageStore'); const { parseEditOptionId } = require('./interactiveReply'); @@ -26,30 +26,33 @@ const SAMPLE_ELEMENTS_DOC = { ], }; -test('actionCheckAllowedEdits lists the tagged elements for a known image', async () => { +test('actionCheckAllowedEdits returns the fixed Edit Product/Discount/Price menu for an Express-catalog image, without calling the Express API', async () => { writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); - expressApi.getTaggedDocument = async (docId) => { - assert.equal(docId, 'urn:doc:1'); - return SAMPLE_ELEMENTS_DOC; - }; + expressApi.getTaggedDocument = async () => { throw new Error('should not be called'); }; const reply = await actionCheckAllowedEdits('phone-1', 'img_1'); - assert.match(reply.historyText, /Croma Earbuds/); - assert.match(reply.historyText, /heading: currently "The X-Phone Pro is here!"/); - assert.match(reply.historyText, /cta: currently/); + assert.equal(reply.type, 'edit_options'); + assert.equal(reply.bodyText, 'What would you like to change?'); + assert.deepEqual(reply.options, [ + { id: 'edit:img_1:product', title: 'Edit Product' }, + { id: 'edit:img_1:discount', title: 'Edit Discount' }, + { id: 'edit:img_1:price', title: 'Edit Price' }, + ]); + assert.match(reply.historyText, /Edit Product/); }); -test('actionCheckAllowedEdits shows the latest edited value instead of the stale original document value', async () => { - writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); - expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; - recordEdits('phone-1b', 'img_1', { cta: '20% off' }); +test('buildTopLevelEditOptions ids parse back to the "product"/"discount"/"price" bare fields', () => { + const { options } = buildTopLevelEditOptions('img_1'); - const reply = await actionCheckAllowedEdits('phone-1b', 'img_1'); - - assert.match(reply.historyText, /cta: currently "20% off"/); - assert.doesNotMatch(reply.historyText, /Available at our store starting 15 Aug 20XX\./); - assert.match(reply.historyText, /heading: currently "The X-Phone Pro is here!"/); + assert.deepEqual( + options.map((option) => parseEditOptionId(option.id)), + [ + { imageId: 'img_1', fieldName: 'product' }, + { imageId: 'img_1', fieldName: 'discount' }, + { imageId: 'img_1', fieldName: 'price' }, + ] + ); }); test('actionCheckAllowedEdits reports unknown images without throwing', async () => { @@ -60,17 +63,6 @@ test('actionCheckAllowedEdits reports unknown images without throwing', async () assert.match(reply, /couldn't find that image/); }); -test('actionCheckAllowedEdits returns a friendly message when the Express API call fails', async () => { - writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); - expressApi.getTaggedDocument = async () => { - throw new Error('getTaggedDocument failed 500: boom'); - }; - - const reply = await actionCheckAllowedEdits('phone-3', 'img_1'); - - assert.match(reply, /couldn't check the allowed edits/); -}); - test('actionEditGraphic returns a disallowed_fields status and makes no generate call for a field outside the tagged elements', async () => { writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; From 939ecd8b0ba26c071510c9332fd0ba4536e1962a Mon Sep 17 00:00:00 2001 From: Priyank Modi Date: Thu, 23 Jul 2026 00:18:15 +0530 Subject: [PATCH 32/35] Present TV model picker as a list with Choose product button --- src/actions.js | 3 ++- src/actions.test.js | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/actions.js b/src/actions.js index d20317ffc3..f712c0facb 100644 --- a/src/actions.js +++ b/src/actions.js @@ -209,7 +209,8 @@ async function actionCheckAllowedEdits(phoneNumber, imageId) { function actionSelectTvModel(imageId) { return { type: 'edit_options', - bodyText: 'Which model would you like to use?', + bodyText: 'Which product do you want?', + buttonText: 'Choose product', options: TV_MODEL_TITLES.map((title) => ({ id: buildValueEditId(imageId, TV_MODEL_EDITS), title, diff --git a/src/actions.test.js b/src/actions.test.js index bf62348047..75cced9705 100644 --- a/src/actions.test.js +++ b/src/actions.test.js @@ -196,11 +196,12 @@ test('actionEditGraphic applies a price edit at exactly the 40% cap (within roun assert.deepEqual(result.changes, { price: 20399 }); }); -test('actionSelectTvModel returns the 3 fixed TV model options with the question body text', () => { +test('actionSelectTvModel returns the 3 fixed TV model options with a list-picker body text and button', () => { const result = actionSelectTvModel('img_1'); assert.equal(result.type, 'edit_options'); - assert.equal(result.bodyText, 'Which model would you like to use?'); + assert.equal(result.bodyText, 'Which product do you want?'); + assert.equal(result.buttonText, 'Choose product'); assert.equal(result.options.length, 3); assert.deepEqual( result.options.map((option) => option.title), From 50145d0db131feff582adb3f8e6c3c02bfb88b07 Mon Sep 17 00:00:00 2001 From: Priyank Modi Date: Thu, 23 Jul 2026 00:18:41 +0530 Subject: [PATCH 33/35] Add WhatsApp list-message support for the product picker --- src/app.js | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/app.js b/src/app.js index 276d602d3d..846af8862e 100644 --- a/src/app.js +++ b/src/app.js @@ -80,15 +80,37 @@ function sendButtons(to, bodyText, options) { }); } +// WhatsApp list messages: a single "menu" button plus up to 10 rows in one section. +function sendList(to, { bodyText, buttonText, options }) { + return whatsappPost({ + messaging_product: 'whatsapp', + to, + type: 'interactive', + interactive: { + type: 'list', + body: { text: bodyText }, + action: { + button: buttonText, + sections: [{ rows: options.map((option) => ({ id: option.id, title: option.title })) }], + }, + }, + }); +} + // WhatsApp reply-button messages cap out at 3 buttons, so options beyond that // go out as additional button messages rather than falling back to a list picker. const BUTTONS_PER_MESSAGE = 3; -async function sendEditOptions(to, { bodyText, options }) { +async function sendEditOptions(to, result) { + const { bodyText, options, buttonText } = result; if (options.length === 0) { await sendText(to, bodyText); return; } + if (buttonText) { + await sendList(to, { bodyText, buttonText, options }); + return; + } for (let i = 0; i < options.length; i += BUTTONS_PER_MESSAGE) { const chunk = options.slice(i, i + BUTTONS_PER_MESSAGE); await sendButtons(to, i === 0 ? bodyText : 'More edits:', chunk); From 5657a1cb4e4afa47292111741849e59d7768e56d Mon Sep 17 00:00:00 2001 From: Priyank Modi Date: Thu, 23 Jul 2026 00:19:45 +0530 Subject: [PATCH 34/35] Add GPT-phrased, language-mirrored edit replies and bare-field discount/price routing Express-catalog edit outcomes are now phrased by a second GPT call that mirrors the user's language style (English or Hinglish), and the fixed menu's discount/price bare fields get explicit system-prompt rules for computing a new price from a requested discount percentage. --- src/app.js | 51 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/src/app.js b/src/app.js index 846af8862e..6d4758abd2 100644 --- a/src/app.js +++ b/src/app.js @@ -8,6 +8,7 @@ const { actionEditGraphic, actionGenerateBulkGraphics, actionSelectTvModel, + buildTopLevelEditOptions, } = require('./actions'); const { parseEditOptionId, messageTextForInteractiveReply } = require('./interactiveReply'); @@ -229,10 +230,18 @@ const tools = [ // ── GPT decision engine ────────────────────────────────────────────────────── +function formatCurrentEdits(currentEdits) { + const entries = Object.entries(currentEdits || {}); + if (entries.length === 0) return ''; + return ` (${entries.map(([key, value]) => `${key}: ${value}`).join(', ')})`; +} + async function decideAction(phoneNumber, userMessage) { const last3 = getHistory(phoneNumber).slice(-3); const trackedImages = getTrackedImages(phoneNumber); - const imagesList = trackedImages.map((image) => `- ${image.id}: ${image.name}`).join('\n'); + const imagesList = trackedImages + .map((image) => `- ${image.id}: ${image.name}${formatCurrentEdits(image.currentEdits)}`) + .join('\n'); const messages = [ { @@ -246,6 +255,11 @@ If the user wants a brand-new creative for an occasion that has no existing temp Choosing between edit_graphic and check_allowed_edits: if the user's message already contains a concrete change and its value (e.g. "make the background marigold", "add my address MG Road Kochi"), call edit_graphic with all of those changes in the edits object. Only call check_allowed_edits when the user asks what can be changed or wants the list of options WITHOUT giving a specific value. When editing, prefer these field names when they apply: headline, background, address, offer. If the user asks to translate a tag's text into another language (e.g. "change the headline to Hindi", "translate the banner to Malayalam"), translate the current text yourself before calling edit_graphic and pass the translated text as the edit value. For Hindi, use Devanagari script (e.g. "उपलब्ध"); for Malayalam, use Malayalam script (e.g. "ഓണം"). Never use a romanized/transliterated form. +When the user taps a menu option for "product", "discount", or "price" (from the fixed Edit Product/Edit Discount/Edit Price menu on an Express-catalog graphic): +- "product": call select_tv_model. +- "discount" or "price" with no value given yet: call ask_for_more_information asking what they'd like the new discount or price to be. +- "discount" WITH a value (a percentage, in English, Hindi, or Hinglish — e.g. "50%", "discount ko 50% kar do", "40% off"): compute the new price yourself as oldPrice × (1 − discountPercent / 100), rounded to the nearest whole number, using the oldPrice shown in the images list below, then call edit_graphic with only { "price": } — never change oldPrice. +- "price" WITH a value: call edit_graphic with { "price": } directly, no computation needed. Images previously sent to this user (reference by id): ${imagesList}`, @@ -268,6 +282,28 @@ ${imagesList}`, return response.choices[0].message; } +// Turns a structured edit outcome into the actual WhatsApp reply text, matching +// the user's language/style (English or Hinglish) rather than a fixed template. +async function phraseOutcome(phoneNumber, userMessage, outcome) { + const response = await openai.chat.completions.create({ + model: openaiModel, + messages: [ + { + role: 'system', + content: `You are a WhatsApp assistant. Given the outcome below, write a short reply to the user. Match the user's language and style — if their message was Hinglish (romanized Hindi mixed with English), reply in Hinglish; otherwise reply in English. Don't invent facts beyond the outcome given. + +Examples of the tone/style to match: +- Success (English): "I have updated product, with price & discount" +- Success (Hinglish): "Maine discount aur price updated kar diya hai" +- Capped (Hinglish): "Iss product pr maximum 40% discount de sakte hain"`, + }, + { role: 'user', content: `User message: ${userMessage}\nOutcome: ${JSON.stringify(outcome)}` }, + ], + }); + + return response.choices[0].message.content; +} + // ── Webhook routes ─────────────────────────────────────────────────────────── app.get('/', (req, res) => { @@ -353,10 +389,19 @@ app.post('/', async (req, res) => { break; } - case 'edit_graphic': + case 'edit_graphic': { await sendText(phoneNumber, '⏳ Applying edits to your graphic...'); - replyText = await actionEditGraphic(phoneNumber, args.image_id, args.edits, { sendImage }); + const result = await actionEditGraphic(phoneNumber, args.image_id, args.edits, { sendImage }); + if (typeof result === 'string') { + replyText = result; + } else { + replyText = await phraseOutcome(phoneNumber, userText, result); + await sendText(phoneNumber, replyText); + await sendEditOptions(phoneNumber, buildTopLevelEditOptions(args.image_id)); + skipSend = true; + } break; + } case 'generate_bulk_graphics': await sendText(phoneNumber, '⏳ Generating graphics from your file, this may take a moment...'); From fbb462f972915979dbcc0ca25c1c0ef4feb1a6d1 Mon Sep 17 00:00:00 2001 From: Priyank Modi Date: Thu, 23 Jul 2026 00:25:25 +0530 Subject: [PATCH 35/35] Add implementation plan; amend spec with discount-cap rounding tolerance The spec's cap check needed a rounding-tolerance clause: whole-rupee price rounding means an at-cap request never implies exactly 40.0000%, so a strict > 40 check would reject the script's own 40%-cap example. --- ...-07-23-product-discount-price-edit-flow.md | 734 ++++++++++++++++++ ...product-discount-price-edit-flow-design.md | 5 +- 2 files changed, 737 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-23-product-discount-price-edit-flow.md diff --git a/docs/superpowers/plans/2026-07-23-product-discount-price-edit-flow.md b/docs/superpowers/plans/2026-07-23-product-discount-price-edit-flow.md new file mode 100644 index 0000000000..e4ecc9222f --- /dev/null +++ b/docs/superpowers/plans/2026-07-23-product-discount-price-edit-flow.md @@ -0,0 +1,734 @@ +# Edit Product/Discount/Price Menu Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the dynamic per-document edit menu with a fixed "Edit Product / Edit Discount / Edit Price" quick-reply menu for Express-catalog graphics, add a WhatsApp list picker for choosing a TV model, generalize the 40%-discount cap to cover GPT-computed price edits, and make the bot's confirmation/rejection replies mirror the user's language (English or Hinglish) via a GPT phrasing pass. + +**Architecture:** `actions.js` gains a fixed-menu builder and a generalized price/discount cap check, and its Express-edit path (`editExpressDesign`) switches from returning hardcoded reply strings to structured outcome objects (`{ status, ... }`). `app.js` gains a WhatsApp list-message sender, a second (tool-free) OpenAI call that phrases those structured outcomes into user-facing text matching the user's language style, and new system-prompt rules routing the fixed menu's three bare fields ("product"/"discount"/"price") to the right tool, including GPT-side discount→price computation. Local/Onam-design edits (`source: 'local'`) are untouched throughout. + +**Tech Stack:** Node.js, Express, `openai` SDK (chat completions, tool calling), WhatsApp Cloud API interactive messages, `node --test` + `node:assert/strict`. + +## Global Constraints + +- `MAX_DISCOUNT_PERCENT = 40` (existing, `src/actions.js:54`) — the hard cap on any discount, however it's expressed. +- New `ROUNDING_TOLERANCE_PERCENT = 0.5` — whole-rupee price rounding means a requested discount at exactly the cap can imply 40.0012% or 39.9988%; the cap check compares against `MAX_DISCOUNT_PERCENT + ROUNDING_TOLERANCE_PERCENT`, not a strict `>`. +- WhatsApp reply-button messages cap out at 3 buttons per message (existing `BUTTONS_PER_MESSAGE = 3`, `src/app.js:85`) — unchanged, still used for the fixed 3-option menu. +- WhatsApp list-message row titles must stay under ~24 characters — the 3 existing TV model titles ("Sony Bravia K-75", "LG UA82 AI", "Samsung UA4") already fit; no truncation logic needed for this feature. +- This feature applies **only** to `source: 'express'` tracked images (the Express-catalog templates). `source: 'local'` (Onam-style canned designs) keeps today's dynamic field-list menu and plain-string replies — do not touch `editLocalDesign`, `localEditElements`, or their tests. +- `app.js` has zero automated unit tests today and cannot be safely `require()`'d in a test file: constructing the module-level `OpenAI` client throws immediately without `OPENAI_API_KEY` set, and the file calls `app.listen(...)` unconditionally at load time. Per existing project convention, do **not** add unit tests for `sendList`, `phraseOutcome`, the dispatch wiring, or the system-prompt changes — verify those via the manual/simulated webhook check in the final task instead. All automated test coverage in this plan lives in `actions.test.js` (pure/deterministic logic only). +- Existing constants stay as-is and must not be redefined: `TV_PLACEHOLDER_IMAGE_URL`, `TV_MODEL_TITLES`, `TV_MODEL_EDITS` (`src/actions.js:7-9`). + +--- + +### Task 1: Generalize the discount cap and switch Express edits to structured outcomes + +**Files:** +- Modify: `src/actions.js:54-63` (keep `MAX_DISCOUNT_PERCENT`/`isDiscountField`/`parsePercent` as-is, add new helpers alongside) +- Modify: `src/actions.js:204-260` (`editExpressDesign`) +- Test: `src/actions.test.js:74-150` (update existing `actionEditGraphic` tests, add new ones) + +**Interfaces:** +- Consumes: `expressApi.getTaggedDocument`, `expressApi.collectTaggedElements`, `expressApi.generateVariation`, `expressApi.pollJobStatus`, `expressApi.pagesForEdits`, `expressApi.buildPreferredDocumentName`, `expressApi.formatAllowedEdits` (all unchanged signatures), `recordEdits`, `withCurrentEdits` (unchanged). +- Produces: `editExpressDesign(phoneNumber, image, edits, { sendImage })` now resolves to one of: + - `{ status: 'api_error', productName, reason: 'lookup_failed' | 'generate_failed' }` + - `{ status: 'disallowed_fields', productName, disallowedKeys, allowedSummary }` + - `{ status: 'discount_capped', productName, maxPercent }` + - `{ status: 'delivery_failed', productName, changes }` + - `{ status: 'success', productName, changes }` + + `actionEditGraphic` is an unchanged pass-through, so it now returns a **string** for `source: 'local'` images and an **object** (one of the shapes above) for `source: 'express'` images. Task 5 updates `app.js`'s dispatch to handle both. + +- [ ] **Step 1: Update the existing `actionEditGraphic` tests in `src/actions.test.js` to expect structured outcomes** + +Replace the test at line 74 (`'actionEditGraphic rejects edits outside the tagged elements...'`): + +```js +test('actionEditGraphic returns a disallowed_fields status and makes no generate call for a field outside the tagged elements', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; + expressApi.generateVariation = async () => { + throw new Error('should not be called'); + }; + let sendImageCalled = false; + const sendImage = async () => { sendImageCalled = true; }; + + const result = await actionEditGraphic('phone-4', 'img_1', { background_color: 'red' }, { sendImage }); + + assert.equal(result.status, 'disallowed_fields'); + assert.equal(result.productName, 'Croma Earbuds'); + assert.deepEqual(result.disallowedKeys, ['background_color']); + assert.match(result.allowedSummary, /Edits allowed on "Croma Earbuds"/); + assert.equal(sendImageCalled, false); +}); +``` + +Replace the test at line 89 (`'actionEditGraphic applies an allowed edit end-to-end...'`) — keep the same setup, change only the assertions after the call: + +```js + const result = await actionEditGraphic('phone-5', 'img_1', { cta: '20% off' }, { sendImage }); + + assert.deepEqual(result, { status: 'success', productName: 'Croma Earbuds', changes: { cta: '20% off' } }); + assert.equal(sentCalls.length, 1); + assert.equal(sentCalls[0].to, 'phone-5'); + assert.equal(sentCalls[0].link, 'https://example.com/thumb.png'); + + const image = findTrackedImage('phone-5', 'img_1'); + assert.deepEqual(image.currentEdits, { cta: '20% off' }); +``` + +Replace the test at line 118 (`'actionEditGraphic returns a friendly message and does not record the edit when generation fails'`) — keep setup, change assertions: + +```js + const result = await actionEditGraphic('phone-6', 'img_1', { cta: '20% off' }, { sendImage }); + + assert.equal(result.status, 'api_error'); + assert.equal(result.reason, 'generate_failed'); + assert.equal(sendImageCalled, false); + + const image = findTrackedImage('phone-6', 'img_1'); + assert.deepEqual(image.currentEdits, {}); +``` + +Replace the test at line 135 (`'actionEditGraphic tells the user delivery failed but keeps the recorded edit when sendImage throws'`) — keep setup, change assertions: + +```js + const result = await actionEditGraphic('phone-7', 'img_1', { cta: '20% off' }, { sendImage }); + + assert.equal(result.status, 'delivery_failed'); + assert.deepEqual(result.changes, { cta: '20% off' }); + + const image = findTrackedImage('phone-7', 'img_1'); + assert.deepEqual(image.currentEdits, { cta: '20% off' }); +``` + +- [ ] **Step 2: Add new tests for the lookup-failure status and the generalized discount cap** + +Add to `src/actions.test.js`, after the tests updated in Step 1: + +```js +test('actionEditGraphic returns an api_error/lookup_failed status when getTaggedDocument fails', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async () => { throw new Error('getTaggedDocument failed 500: boom'); }; + + const result = await actionEditGraphic('phone-8', 'img_1', { cta: '20% off' }, { sendImage: async () => {} }); + + assert.equal(result.status, 'api_error'); + assert.equal(result.reason, 'lookup_failed'); +}); + +const TV_ELEMENTS_DOC = { + documentPages: [ + { + pageNumber: 1, + taggedElements: [ + { name: 'productImage', type: 'image', value: '' }, + { name: 'oldPrice', type: 'text', value: '' }, + { name: 'price', type: 'text', value: '' }, + ], + }, + ], +}; + +test('actionEditGraphic rejects a price edit implying more than 40% off and makes no generate call', async () => { + writeFixtureCatalog([{ id: 'img_2', name: 'TV Product', docId: 'urn:doc:2' }]); + expressApi.getTaggedDocument = async () => TV_ELEMENTS_DOC; + expressApi.generateVariation = async () => { throw new Error('should not be called'); }; + recordEdits('phone-9', 'img_2', { productImage: 'https://example.com/tv.png', oldPrice: 33999, price: 27199 }); + + const result = await actionEditGraphic('phone-9', 'img_2', { price: 17000 }, { sendImage: async () => {} }); + + assert.equal(result.status, 'discount_capped'); + assert.equal(result.maxPercent, 40); +}); + +test('actionEditGraphic applies a price edit at exactly the 40% cap (within rounding tolerance)', async () => { + writeFixtureCatalog([{ id: 'img_2', name: 'TV Product', docId: 'urn:doc:2' }]); + expressApi.getTaggedDocument = async () => TV_ELEMENTS_DOC; + expressApi.generateVariation = async (docId, tagMappings) => { + assert.deepEqual(tagMappings, { productImage: 'https://example.com/tv.png', oldPrice: 33999, price: 20399 }); + return { jobId: 'job-2', statusUrl: 'https://express-api.adobe.io/status/job-2' }; + }; + expressApi.pollJobStatus = async () => ({ status: 'succeeded', document: { thumbnailUrl: 'https://example.com/thumb2.png' } }); + recordEdits('phone-10', 'img_2', { productImage: 'https://example.com/tv.png', oldPrice: 33999, price: 27199 }); + + const result = await actionEditGraphic('phone-10', 'img_2', { price: 20399 }, { sendImage: async () => {} }); + + assert.equal(result.status, 'success'); + assert.deepEqual(result.changes, { price: 20399 }); +}); +``` + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `npm test 2>&1 | grep -A3 "actionEditGraphic"` +Expected: FAIL — the updated/new tests fail because `editExpressDesign` still returns strings and has no discount-cap generalization yet. + +- [ ] **Step 4: Implement the generalized cap and structured outcomes in `src/actions.js`** + +Add these two helpers directly after the existing `parsePercent` function (after line 63): + +```js +const ROUNDING_TOLERANCE_PERCENT = 0.5; + +function findOldPriceKey(keys) { + return keys.find((key) => /^old.?price$/i.test(key)); +} + +function findNewPriceKey(keys, oldPriceKey) { + return keys.find((key) => key !== oldPriceKey && /(^|_)price$/i.test(key)); +} + +// Covers both "discount ko 50% kar do" (GPT computes a price from oldPrice) and a +// direct "set price to X" request — either way, a price drop of more than the cap +// (plus rounding slack for whole-rupee prices) is rejected. +function impliesExcessiveDiscount(mergedEdits, requestedKeys) { + const keys = Object.keys(mergedEdits); + const oldPriceKey = findOldPriceKey(keys); + const newPriceKey = findNewPriceKey(keys, oldPriceKey); + if (!oldPriceKey || !newPriceKey || !requestedKeys.includes(newPriceKey)) return false; + + const oldPrice = Number(mergedEdits[oldPriceKey]); + const newPrice = Number(mergedEdits[newPriceKey]); + if (!Number.isFinite(oldPrice) || oldPrice <= 0 || !Number.isFinite(newPrice)) return false; + + const impliedDiscountPercent = ((oldPrice - newPrice) / oldPrice) * 100; + return impliedDiscountPercent > MAX_DISCOUNT_PERCENT + ROUNDING_TOLERANCE_PERCENT; +} +``` + +Replace the whole `editExpressDesign` function (`src/actions.js:204-260`) with: + +```js +async function editExpressDesign(phoneNumber, image, edits, { sendImage }) { + let elements; + try { + const doc = await expressApi.getTaggedDocument(image.docId); + elements = expressApi.collectTaggedElements(doc); + } catch (err) { + console.error('[editExpressDesign] Express API error', { docId: image.docId, message: err.message }); + return { status: 'api_error', productName: image.name, reason: 'lookup_failed' }; + } + + const allowedNames = elements.map((element) => element.name); + const requestedKeys = Object.keys(edits || {}); + const disallowedKeys = requestedKeys.filter((key) => !allowedNames.includes(key)); + + if (disallowedKeys.length > 0) { + const elementsWithCurrentEdits = withCurrentEdits(elements, image.currentEdits); + return { + status: 'disallowed_fields', + productName: image.name, + disallowedKeys, + allowedSummary: expressApi.formatAllowedEdits(image.name, elementsWithCurrentEdits), + }; + } + + const oversizedDiscountKeys = requestedKeys.filter((key) => { + if (!isDiscountField(key)) return false; + const percent = parsePercent(edits[key]); + return percent !== null && percent > MAX_DISCOUNT_PERCENT; + }); + + const mergedEdits = { ...image.currentEdits, ...edits }; + + if (oversizedDiscountKeys.length > 0 || impliesExcessiveDiscount(mergedEdits, requestedKeys)) { + return { status: 'discount_capped', productName: image.name, maxPercent: MAX_DISCOUNT_PERCENT }; + } + + const pages = expressApi.pagesForEdits(elements, Object.keys(mergedEdits)); + const preferredDocumentName = expressApi.buildPreferredDocumentName(image.name); + + let thumbnailUrl; + try { + const { statusUrl } = await expressApi.generateVariation(image.docId, mergedEdits, pages, preferredDocumentName); + const result = await expressApi.pollJobStatus(statusUrl); + thumbnailUrl = result.document.thumbnailUrl; + console.log('[edit:express] resolved image', { imageId: image.id, docId: image.docId, thumbnailUrl }); + } catch (err) { + console.error('[editExpressDesign] generate/poll error', { docId: image.docId, message: err.message }); + return { status: 'api_error', productName: image.name, reason: 'generate_failed' }; + } + + recordEdits(phoneNumber, image.id, edits); + + try { + await sendImage(phoneNumber, thumbnailUrl); + } catch (err) { + console.error('[editExpressDesign] sendImage error', { docId: image.docId, message: err.message }); + return { status: 'delivery_failed', productName: image.name, changes: edits }; + } + + return { status: 'success', productName: image.name, changes: edits }; +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `npm test 2>&1 | tail -20` +Expected: PASS — `# fail 0`. + +- [ ] **Step 6: Commit** + +```bash +git add src/actions.js src/actions.test.js +git commit -m "Generalize discount cap to price edits and return structured edit outcomes" +``` + +--- + +### Task 2: Fixed Edit Product/Discount/Price menu for Express-catalog images + +**Files:** +- Modify: `src/actions.js:144-175` (`actionCheckAllowedEdits`) +- Modify: `src/actions.js:268-275` (`module.exports`) +- Test: `src/actions.test.js:1-73` (remove 2 obsolete tests, add 2 new ones) + +**Interfaces:** +- Consumes: `findTrackedImage`, `parseEditOptionId` (test-only import), `expressApi.buildEditOptions`/`formatAllowedEdits` (unchanged, still used for the local-design branch only). +- Produces: `buildTopLevelEditOptions(imageId)` → `{ type: 'edit_options', bodyText: 'What would you like to change?', options: [{id, title}, ...], historyText }`, newly exported from `actions.js`. `actionCheckAllowedEdits` returns this directly for `source: 'express'` images (no Express API call), and its previous local-design branch is unchanged. + +- [ ] **Step 1: Update `src/actions.test.js` — remove obsolete Express-branch tests, add new fixed-menu tests** + +Delete these three tests entirely (lines 29-72): `'actionCheckAllowedEdits lists the tagged elements for a known image'`, `'actionCheckAllowedEdits shows the latest edited value instead of the stale original document value'`, `'actionCheckAllowedEdits returns a friendly message when the Express API call fails'`. (They tested behavior — inspecting the real tagged document to build the menu — that no longer exists for Express-catalog images once the menu is fixed. Task 1's `lookup_failed` test already covers the "Express API fails" case, but now on `actionEditGraphic`, not `actionCheckAllowedEdits`.) + +Keep `'actionCheckAllowedEdits reports unknown images without throwing'` (lines 55-61) unchanged. + +Add these two tests in its place, and update the import line at the top of the file to include `buildTopLevelEditOptions`: + +```js +const { actionCheckAllowedEdits, actionEditGraphic, actionSelectTvModel, buildTopLevelEditOptions } = require('./actions'); +``` + +```js +test('actionCheckAllowedEdits returns the fixed Edit Product/Discount/Price menu for an Express-catalog image, without calling the Express API', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async () => { throw new Error('should not be called'); }; + + const reply = await actionCheckAllowedEdits('phone-1', 'img_1'); + + assert.equal(reply.type, 'edit_options'); + assert.equal(reply.bodyText, 'What would you like to change?'); + assert.deepEqual(reply.options, [ + { id: 'edit:img_1:product', title: 'Edit Product' }, + { id: 'edit:img_1:discount', title: 'Edit Discount' }, + { id: 'edit:img_1:price', title: 'Edit Price' }, + ]); + assert.match(reply.historyText, /Edit Product/); +}); + +test('buildTopLevelEditOptions ids parse back to the "product"/"discount"/"price" bare fields', () => { + const { options } = buildTopLevelEditOptions('img_1'); + + assert.deepEqual( + options.map((option) => parseEditOptionId(option.id)), + [ + { imageId: 'img_1', fieldName: 'product' }, + { imageId: 'img_1', fieldName: 'discount' }, + { imageId: 'img_1', fieldName: 'price' }, + ] + ); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npm test 2>&1 | grep -B2 -A6 "fixed Edit Product"` +Expected: FAIL — `buildTopLevelEditOptions` is not exported yet and `actionCheckAllowedEdits` still calls the Express API. + +- [ ] **Step 3: Implement `buildTopLevelEditOptions` and wire it into `actionCheckAllowedEdits`** + +Add above `actionCheckAllowedEdits` (before line 144): + +```js +const TOP_LEVEL_EDIT_FIELDS = [ + { fieldName: 'product', title: 'Edit Product' }, + { fieldName: 'discount', title: 'Edit Discount' }, + { fieldName: 'price', title: 'Edit Price' }, +]; + +function buildTopLevelEditOptions(imageId) { + return { + type: 'edit_options', + bodyText: 'What would you like to change?', + options: TOP_LEVEL_EDIT_FIELDS.map(({ fieldName, title }) => ({ + id: `edit:${imageId}:${fieldName}`, + title, + })), + historyText: 'What would you like to change? (Edit Product / Edit Discount / Edit Price)', + }; +} +``` + +Replace the body of `actionCheckAllowedEdits` (`src/actions.js:144-175`) with: + +```js +async function actionCheckAllowedEdits(phoneNumber, imageId) { + const image = findTrackedImage(phoneNumber, imageId); + console.log('[action:check_allowed_edits]', { phoneNumber, imageId, source: image?.source ?? 'not_found' }); + if (!image) { + return formatUnknownImageMessage(phoneNumber); + } + + if (image.source === 'express') { + return buildTopLevelEditOptions(imageId); + } + + const elements = localEditElements(image); + return { + type: 'edit_options', + bodyText: 'What would you like to change?', + options: expressApi.buildEditOptions(elements, imageId), + historyText: expressApi.formatAllowedEdits(image.name, elements), + }; +} +``` + +Add `buildTopLevelEditOptions` to `module.exports` (`src/actions.js:268-275`): + +```js +module.exports = { + actionListCampaignGraphics, + actionCreateDesign, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, + actionSelectTvModel, + buildTopLevelEditOptions, +}; +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npm test 2>&1 | tail -20` +Expected: PASS — `# fail 0`. + +- [ ] **Step 5: Commit** + +```bash +git add src/actions.js src/actions.test.js +git commit -m "Replace dynamic edit menu with fixed Edit Product/Discount/Price for Express-catalog images" +``` + +--- + +### Task 3: Product picker copy — "Which product do you want?" + "Choose product" button + +**Files:** +- Modify: `src/actions.js:177-186` (`actionSelectTvModel`) +- Test: `src/actions.test.js:152-178` (update one test) + +**Interfaces:** +- Consumes: `buildValueEditId` (unchanged), `TV_MODEL_TITLES`, `TV_MODEL_EDITS` (unchanged constants). +- Produces: `actionSelectTvModel(imageId)` now also returns `buttonText: 'Choose product'`, and `bodyText` changes from `'Which model would you like to use?'` to `'Which product do you want?'`. This `buttonText` field is what Task 4's `sendEditOptions` uses to decide to send a WhatsApp list instead of buttons. + +- [ ] **Step 1: Update the existing test** + +Replace `'actionSelectTvModel returns the 3 fixed TV model options with the question body text'` (`src/actions.test.js:152-162`): + +```js +test('actionSelectTvModel returns the 3 fixed TV model options with a list-picker body text and button', () => { + const result = actionSelectTvModel('img_1'); + + assert.equal(result.type, 'edit_options'); + assert.equal(result.bodyText, 'Which product do you want?'); + assert.equal(result.buttonText, 'Choose product'); + assert.equal(result.options.length, 3); + assert.deepEqual( + result.options.map((option) => option.title), + ['Sony Bravia K-75', 'LG UA82 AI', 'Samsung UA4'] + ); +}); +``` + +Leave `'actionSelectTvModel encodes the same fixed productImage/oldPrice/price edits into every option id'` unchanged. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npm test 2>&1 | grep -A6 "list-picker body text"` +Expected: FAIL — actual `bodyText` is still `'Which model would you like to use?'` and `buttonText` is `undefined`. + +- [ ] **Step 3: Implement** + +Replace `actionSelectTvModel` (`src/actions.js:177-186`): + +```js +function actionSelectTvModel(imageId) { + return { + type: 'edit_options', + bodyText: 'Which product do you want?', + buttonText: 'Choose product', + options: TV_MODEL_TITLES.map((title) => ({ + id: buildValueEditId(imageId, TV_MODEL_EDITS), + title, + })), + }; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npm test 2>&1 | tail -20` +Expected: PASS — `# fail 0`. + +- [ ] **Step 5: Commit** + +```bash +git add src/actions.js src/actions.test.js +git commit -m "Present TV model picker as a list with Choose product button" +``` + +--- + +### Task 4: WhatsApp list-message support in `app.js` + +**Files:** +- Modify: `src/app.js:83-96` (`sendEditOptions`, new `sendList`) + +**Interfaces:** +- Consumes: `whatsappPost` (existing), the `buttonText` field added to `actionSelectTvModel`'s result in Task 3. +- Produces: `sendList(to, { bodyText, buttonText, options })` — new. `sendEditOptions(to, result)` now branches: list when `result.buttonText` is present, otherwise the existing button-chunking behavior (used by the Task 2 fixed 3-option menu, which has no `buttonText`). + +No automated tests for this step — see Global Constraints (`app.js` isn't unit-testable without an `OPENAI_API_KEY` and starts a server on load). It's verified in Task 6's manual/simulated webhook check. + +- [ ] **Step 1: Add `sendList` and update `sendEditOptions`** + +Insert a new function directly after `sendButtons` (`src/app.js:81`, before the `BUTTONS_PER_MESSAGE` comment): + +```js +// WhatsApp list messages: a single "menu" button plus up to 10 rows in one section. +function sendList(to, { bodyText, buttonText, options }) { + return whatsappPost({ + messaging_product: 'whatsapp', + to, + type: 'interactive', + interactive: { + type: 'list', + body: { text: bodyText }, + action: { + button: buttonText, + sections: [{ rows: options.map((option) => ({ id: option.id, title: option.title })) }], + }, + }, + }); +} +``` + +Replace `sendEditOptions` (`src/app.js:87-96`): + +```js +async function sendEditOptions(to, result) { + const { bodyText, options, buttonText } = result; + if (options.length === 0) { + await sendText(to, bodyText); + return; + } + if (buttonText) { + await sendList(to, { bodyText, buttonText, options }); + return; + } + for (let i = 0; i < options.length; i += BUTTONS_PER_MESSAGE) { + const chunk = options.slice(i, i + BUTTONS_PER_MESSAGE); + await sendButtons(to, i === 0 ? bodyText : 'More edits:', chunk); + } +} +``` + +- [ ] **Step 2: Run the full test suite to confirm no regressions** + +Run: `npm test 2>&1 | tail -20` +Expected: PASS — `# fail 0` (this task touches no tested code paths; this just guards against a typo breaking something else). + +- [ ] **Step 3: Commit** + +```bash +git add src/app.js +git commit -m "Add WhatsApp list-message support for the product picker" +``` + +--- + +### Task 5: Dynamic, language-mirrored replies + bare-field GPT routing + menu resurfacing + +**Files:** +- Modify: `src/app.js:1-12` (imports) +- Modify: `src/app.js:210-247` (`decideAction` — images list, system prompt) +- Modify: `src/app.js:313-337` (dispatch: `check_allowed_edits` unaffected structurally, `edit_graphic` case rewritten) +- New: `phraseOutcome` function in `src/app.js` + +**Interfaces:** +- Consumes: `buildTopLevelEditOptions` (Task 2, imported from `./actions`), `actionEditGraphic`'s new return contract (Task 1: string for local, structured object for express), `image.currentEdits` (existing, from `getTrackedImages`). +- Produces: `phraseOutcome(phoneNumber, userMessage, outcome)` — new, not exported (only used within `app.js`'s dispatch). + +No automated tests for this step — see Global Constraints. Verified in Task 6. + +- [ ] **Step 1: Import `buildTopLevelEditOptions`** + +In `src/app.js:4-11`, change: + +```js +const { + actionListCampaignGraphics, + actionCreateDesign, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, + actionSelectTvModel, +} = require('./actions'); +``` + +to: + +```js +const { + actionListCampaignGraphics, + actionCreateDesign, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, + actionSelectTvModel, + buildTopLevelEditOptions, +} = require('./actions'); +``` + +- [ ] **Step 2: Show current field values in the images list, and add the bare-field routing + discount-computation rules to the system prompt** + +In `decideAction` (`src/app.js:210-247`), replace the `imagesList` line and the system prompt string. + +Replace: + +```js + const trackedImages = getTrackedImages(phoneNumber); + const imagesList = trackedImages.map((image) => `- ${image.id}: ${image.name}`).join('\n'); +``` + +with: + +```js + const trackedImages = getTrackedImages(phoneNumber); + const imagesList = trackedImages + .map((image) => `- ${image.id}: ${image.name}${formatCurrentEdits(image.currentEdits)}`) + .join('\n'); +``` + +Add this helper function directly above `decideAction` (before line 210): + +```js +function formatCurrentEdits(currentEdits) { + const entries = Object.entries(currentEdits || {}); + if (entries.length === 0) return ''; + return ` (${entries.map(([key, value]) => `${key}: ${value}`).join(', ')})`; +} +``` + +In the system prompt template string (`src/app.js:218-229`), insert this new paragraph immediately before the final `Images previously sent to this user (reference by id):` line: + +``` +When the user taps a menu option for "product", "discount", or "price" (from the fixed Edit Product/Edit Discount/Edit Price menu on an Express-catalog graphic): +- "product": call select_tv_model. +- "discount" or "price" with no value given yet: call ask_for_more_information asking what they'd like the new discount or price to be. +- "discount" WITH a value (a percentage, in English, Hindi, or Hinglish — e.g. "50%", "discount ko 50% kar do", "40% off"): compute the new price yourself as oldPrice × (1 − discountPercent / 100), rounded to the nearest whole number, using the oldPrice shown in the images list below, then call edit_graphic with only { "price": } — never change oldPrice. +- "price" WITH a value: call edit_graphic with { "price": } directly, no computation needed. +``` + +- [ ] **Step 3: Add `phraseOutcome`** + +Add this function directly after `decideAction` (after line 247, before the `// ── Webhook routes ──` comment): + +```js +// Turns a structured edit outcome into the actual WhatsApp reply text, matching +// the user's language/style (English or Hinglish) rather than a fixed template. +async function phraseOutcome(phoneNumber, userMessage, outcome) { + const response = await openai.chat.completions.create({ + model: openaiModel, + messages: [ + { + role: 'system', + content: `You are a WhatsApp assistant. Given the outcome below, write a short reply to the user. Match the user's language and style — if their message was Hinglish (romanized Hindi mixed with English), reply in Hinglish; otherwise reply in English. Don't invent facts beyond the outcome given. + +Examples of the tone/style to match: +- Success (English): "I have updated product, with price & discount" +- Success (Hinglish): "Maine discount aur price updated kar diya hai" +- Capped (Hinglish): "Iss product pr maximum 40% discount de sakte hain"`, + }, + { role: 'user', content: `User message: ${userMessage}\nOutcome: ${JSON.stringify(outcome)}` }, + ], + }); + + return response.choices[0].message.content; +} +``` + +- [ ] **Step 4: Rewrite the `edit_graphic` dispatch case** + +Replace the `case 'edit_graphic':` block (`src/app.js:334-337`): + +```js + case 'edit_graphic': + await sendText(phoneNumber, '⏳ Applying edits to your graphic...'); + replyText = await actionEditGraphic(phoneNumber, args.image_id, args.edits, { sendImage }); + break; +``` + +with: + +```js + case 'edit_graphic': { + await sendText(phoneNumber, '⏳ Applying edits to your graphic...'); + const result = await actionEditGraphic(phoneNumber, args.image_id, args.edits, { sendImage }); + if (typeof result === 'string') { + replyText = result; + } else { + replyText = await phraseOutcome(phoneNumber, userText, result); + await sendText(phoneNumber, replyText); + await sendEditOptions(phoneNumber, buildTopLevelEditOptions(args.image_id)); + skipSend = true; + } + break; + } +``` + +- [ ] **Step 5: Run the full test suite to confirm no regressions** + +Run: `npm test 2>&1 | tail -20` +Expected: PASS — `# fail 0` (no test file exercises `app.js`, so this step is a syntax/regression guard for the rest of the suite). + +- [ ] **Step 6: Commit** + +```bash +git add src/app.js +git commit -m "Add GPT-phrased, language-mirrored edit replies and bare-field discount/price routing" +``` + +--- + +### Task 6: Manual/simulated webhook verification of the full script + +**Files:** none (verification only) + +**Interfaces:** none — this task exercises the running app end-to-end via simulated webhook POST bodies, the same way prior features in this repo (e.g. the TV product-swap design) were manually verified before merge. + +- [ ] **Step 1: Start the app with required env vars** + +Run (fill in real values or test-double values for `WHATSAPP_TOKEN`/`OPENAI_API_KEY` per your local `.env` setup): + +```bash +npm start +``` + +Expected: `Listening on port 3000` with no startup errors. + +- [ ] **Step 2: Simulate each turn of the script as a webhook POST** + +For each user turn below, POST a WhatsApp-shaped webhook body to `http://localhost:3000/` (a `text` message for free-text turns, an `interactive.button_reply`/`interactive.list_reply` for tap turns) and confirm the described bot behavior in the server logs / connected WhatsApp test number: + +1. "I want to edit this" (text) → 3-button menu: Edit Product / Edit Discount / Edit Price. +2. Tap "Edit Product" → WhatsApp **list** message "Which product do you want?" with "Choose product" button, 3 rows (Sony Bravia K-75 / LG UA82 AI / Samsung UA4). +3. Tap "Sony Bravia K-75" → image updated to the Sony placeholder, oldPrice 33999 / price 27199 applied; phrased confirmation text sent; 3-button menu resent. +4. "discount ko 50% kar do" (text, Hinglish) → no image sent; a Hinglish denial mentioning the 40% cap; 3-button menu resent. +5. "Theek h, 40% hi kar do" (text, Hinglish) → image updated (price 20399, oldPrice unchanged 33999); Hinglish confirmation; 3-button menu resent. + +Expected: each step's bot behavior matches the description above. Exact reply wording will vary turn to turn (GPT-generated) — verify meaning and language register, not literal text. + +- [ ] **Step 3: Report results** + +If any step diverges from the expected behavior, note which step and the actual vs. expected outcome before proceeding — do not mark this task done with an unresolved mismatch. diff --git a/docs/superpowers/specs/2026-07-23-product-discount-price-edit-flow-design.md b/docs/superpowers/specs/2026-07-23-product-discount-price-edit-flow-design.md index 628b33502d..78add2bad3 100644 --- a/docs/superpowers/specs/2026-07-23-product-discount-price-edit-flow-design.md +++ b/docs/superpowers/specs/2026-07-23-product-discount-price-edit-flow-design.md @@ -131,7 +131,8 @@ if (oldPriceKey && newPriceKey && requestedKeys.includes(newPriceKey)) { const oldPrice = Number(mergedEdits[oldPriceKey]); const newPrice = Number(mergedEdits[newPriceKey]); const impliedDiscountPercent = ((oldPrice - newPrice) / oldPrice) * 100; - if (impliedDiscountPercent > MAX_DISCOUNT_PERCENT) { + const ROUNDING_TOLERANCE_PERCENT = 0.5; + if (impliedDiscountPercent > MAX_DISCOUNT_PERCENT + ROUNDING_TOLERANCE_PERCENT) { return { status: 'discount_capped', productName: image.name, maxPercent: MAX_DISCOUNT_PERCENT }; } } @@ -139,7 +140,7 @@ if (oldPriceKey && newPriceKey && requestedKeys.includes(newPriceKey)) { This replaces the existing `oversizedDiscountKeys` literal-field check (kept as a fallback for any future literal `discount_text`-style field, unchanged logic there). -Boundary case: exactly 40% is allowed (matches the script: 33999 × 0.6 = 20399.4 → rounds to 20399, exactly a 40.0% discount, and step 11 shows this succeeding). +Boundary case: exactly 40% is allowed. Note that rounding the computed price to a whole number introduces sub-percent noise — e.g. 33999 × 0.6 = 20399.4, and whichever way GPT rounds that (20399 or 20400), the *implied* discount off the resulting whole-rupee price is 40.0012% or 39.9988%, never exactly 40.0000%. The cap check therefore compares against `MAX_DISCOUNT_PERCENT + 0.5` (a half-point rounding tolerance), not a strict `> 40`, so whole-rupee rounding on either side of the requested percentage never flips a legitimate at-cap request into a rejection. A genuinely excessive ask (e.g. 50%) is far outside this tolerance and still rejected. ## 5. Structured outcomes + dynamic, language-mirrored replies