diff --git a/.gitignore b/.gitignore index fd4f2b066b..154f92d291 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ node_modules .DS_Store +.superpowers +.env diff --git a/app.js b/app.js deleted file mode 100644 index 5ab128e4b4..0000000000 --- a/app.js +++ /dev/null @@ -1,61 +0,0 @@ -const express = require("express"); -const app = express(); -const port = process.env.PORT || 3001; - -app.get("/", (req, res) => res.type('html').send(html)); - -const server = app.listen(port, () => console.log(`Example app listening on port ${port}!`)); - -server.keepAliveTimeout = 120 * 1000; -server.headersTimeout = 120 * 1000; - -const html = ` - - - - Hello from Render! - - - - - -
- Hello from Render! -
- - -` diff --git a/data/express-templates.json b/data/express-templates.json new file mode 100644 index 0000000000..909564c8a0 --- /dev/null +++ b/data/express-templates.json @@ -0,0 +1,3 @@ +[ + { "id": "img_1", "name": "Croma Diwali offer", "docId": "urn:aaid:sc:AP:4b4b25bb-0949-588e-bd7b-76d0e9a113bd" } +] diff --git a/data/onam-design.json b/data/onam-design.json new file mode 100644 index 0000000000..0ff590537e --- /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?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" }, + { "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/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/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/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/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/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-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/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/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/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..78add2bad3 --- /dev/null +++ b/docs/superpowers/specs/2026-07-23-product-discount-price-edit-flow-design.md @@ -0,0 +1,197 @@ +# 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; + 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 }; + } +} +``` + +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. 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 + +`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. diff --git a/package.json b/package.json index 43954fe280..f68ca9be99 100644 --- a/package.json +++ b/package.json @@ -2,15 +2,17 @@ "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": { - "express": "^5.0.0" + "express": "^5.0.0", + "openai": "^6.45.0" } } diff --git a/render.yaml b/render.yaml index 9583d53df2..869c1badac 100644 --- a/render.yaml +++ b/render.yaml @@ -4,7 +4,35 @@ 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 + - key: VERIFY_TOKEN + sync: false + - key: WHATSAPP_PHONE_NUMBER_ID + sync: false + - key: WHATSAPP_TOKEN + sync: false + - key: OPENAI_API_KEY + sync: false + - key: OPENAI_BASE_URL + 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..82e179a6a6 --- /dev/null +++ b/src/actions.js @@ -0,0 +1,76 @@ +// 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 { getTrackedImages, findTrackedImage } = require('./imageStore'); +const expressFlow = require('./expressFlow'); +const localFlow = require('./localFlow'); + +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() { + console.log('[action:list_campaign_graphics]'); + // TODO: fetch from campaign API + return 'Graphics in your current campaign:\n1. Croma Earbuds'; +} + +// Flow 2 (local/canned) — create a brand-new design from a text description. +function actionCreateDesign(phoneNumber, args, ctx) { + return localFlow.createDesign(phoneNumber, args, ctx); +} + +// Flow 1 (express/catalog) — offer the fixed TV models as quick replies. +function actionSelectTvModel(imageId) { + return expressFlow.selectTvModel(imageId); +} + +// Flow 1 (express/catalog) — the fixed Edit Product/Discount/Price menu, reused +// by app.js to resurface the menu after every edit outcome on this flow. +function buildTopLevelEditOptions(imageId) { + return expressFlow.buildTopLevelEditOptions(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); + + return image.source === 'local' + ? localFlow.checkAllowedEdits(image) + : expressFlow.checkAllowedEdits(image); +} + +// Router: resolve the image, then hand off to the flow that owns it. +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, sendText }) + : expressFlow.editGraphic(phoneNumber, image, edits, { sendImage, sendText }); +} + +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, + actionSelectTvModel, + buildTopLevelEditOptions, +}; diff --git a/src/actions.test.js b/src/actions.test.js new file mode 100644 index 0000000000..a0d2d6b2b6 --- /dev/null +++ b/src/actions.test.js @@ -0,0 +1,54 @@ +// 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 { 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`); + fs.writeFileSync(fixturePath, JSON.stringify(entries)); + process.env.EXPRESS_TEMPLATES_FILE = fixturePath; +} + +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 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-r1', 'img_nope'); + + assert.match(reply, /couldn't find that image/); +}); + +test('actionEditGraphic reports unknown images without throwing', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + + const reply = await actionEditGraphic('phone-r2', 'img_nope', { x: 'y' }, { sendImage: async () => {} }); + + assert.match(reply, /couldn't find that image/); +}); + +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 }); + + const reply = await actionEditGraphic('phone-r3', 'local_1', { address: 'MG Road' }, { sendImage }); + + 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 new file mode 100644 index 0000000000..4b97f515dd --- /dev/null +++ b/src/app.js @@ -0,0 +1,495 @@ +const express = require('express'); +const OpenAI = require('openai'); +const { getTrackedImages } = require('./imageStore'); +const { + actionListCampaignGraphics, + actionCreateDesign, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, + actionSelectTvModel, + buildTopLevelEditOptions, +} = require('./actions'); +const { parseEditOptionId, messageTextForInteractiveReply } = require('./interactiveReply'); + +const app = express(); +app.use(express.json()); + +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, baseURL: openaiBaseURL }); + +// 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: { + 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(); +} + +function sendText(to, text) { + return whatsappPost({ messaging_product: 'whatsapp', to, type: 'text', text: { body: text } }); +} + +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. +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 } })), + }, + }, + }); +} + +// 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, 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); + } +} + +// 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 = [ + { + type: 'function', + function: { + name: 'list_campaign_graphics', + description: 'List all graphics available in the current campaign', + 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"' }, + includeAddress: { + type: 'boolean', + description: "Set true if the user has agreed to include their store address on the design; otherwise false.", + }, + }, + required: ['occasion'], + }, + }, + }, + { + type: 'function', + function: { + name: 'ask_for_more_information', + 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'], + }, + }, + }, + { + type: 'function', + function: { + name: 'check_allowed_edits', + description: + '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: { + 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: '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: { + name: 'edit_graphic', + description: + '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: { + 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'], + }, + }, + }, + { + 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' }, + }, + }, + }, + }, +]; + +// ── 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) { + // 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}${formatCurrentEdits(image.currentEdits)}`) + .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. +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. +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. +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}`, + }, + ...recentHistory, + { 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: openaiModel, + messages, + tools, + tool_choice: 'required', + }); + + 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) => { + 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); + } else { + res.status(403).end(); + } +}); + +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)); + + res.status(200).end(); + + try { + const messages = req.body?.entry?.[0]?.changes?.[0]?.value?.messages; + 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 interactiveReply = message?.interactive?.button_reply || message?.interactive?.list_reply; + const userText = message?.text?.body || (interactiveReply && messageTextForInteractiveReply(interactiveReply)); + 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; + let skipSend = false; + + switch (action) { + case 'list_campaign_graphics': + await sendText(phoneNumber, '⏳ Fetching campaign graphics...'); + replyText = await actionListCampaignGraphics(); + break; + + case 'create_design': { + // Progress is streamed from inside the flow (with the product/offer context). + // 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 }); + 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': { + await sendText(phoneNumber, '⏳ Checking allowed edits...'); + 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 'select_tv_model': { + const result = actionSelectTvModel(args.image_id); + await sendEditOptions(phoneNumber, result); + replyText = result.bodyText; + skipSend = true; + break; + } + + case 'edit_graphic': { + // Progress is streamed from inside the flow. Local-flow outcomes are + // either a plain string (guardrail rejection) or {skipSend:true, + // historyText} (success, image+caption already sent). Express-flow + // outcomes are always a structured {status, ...} object — phrased here + // to match the user's language, delivered with that phrasing as the + // image caption, then followed by the fixed Edit Product/Discount/Price menu. + const result = await actionEditGraphic(phoneNumber, args.image_id, args.edits, { sendImage, sendText }); + if (typeof result === 'string') { + replyText = result; + } else if (result.status) { + replyText = await phraseOutcome(phoneNumber, userText, result); + if (result.status === 'success') { + try { + await sendImage(phoneNumber, result.thumbnailUrl, replyText); + } catch (err) { + console.error('[edit_graphic] sendImage error', { message: err.message }); + replyText = `Updated "${result.productName}", but I couldn't send the image right now — try asking me to resend it.`; + await sendText(phoneNumber, replyText); + } + } else { + await sendText(phoneNumber, replyText); + } + await sendEditOptions(phoneNumber, buildTopLevelEditOptions(args.image_id)); + skipSend = true; + } else { + replyText = result.historyText; + skipSend = true; + } + 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."; + } + + if (!skipSend) { + await sendText(phoneNumber, replyText); + } + appendHistory(phoneNumber, 'assistant', replyText); + } + } catch (err) { + 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/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 new file mode 100644 index 0000000000..d6a579d6e4 --- /dev/null +++ b/src/express/expressApi.js @@ -0,0 +1,96 @@ +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'; +} + +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 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, + buildEditOptions, + 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/expressFlow.js b/src/expressFlow.js new file mode 100644 index 0000000000..00dc6199ab --- /dev/null +++ b/src/expressFlow.js @@ -0,0 +1,171 @@ +// ── 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 hand back a structured +// outcome for the caller (app.js) to phrase, deliver, and follow up on. +// +// 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 { 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; +const ROUNDING_TOLERANCE_PERCENT = 0.5; + +// Express-catalog graphics always show this fixed 3-option menu rather than a +// per-document field list; tapping one produces a bare "product"/"discount"/ +// "price" field id via the existing interactive-reply scheme (interactiveReply.js). +const TOP_LEVEL_EDIT_FIELDS = [ + { fieldName: 'product', title: 'Edit Product' }, + { fieldName: 'discount', title: 'Edit Discount' }, + { fieldName: 'price', title: 'Edit Price' }, +]; + +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; +} + +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; +} + +// TV model picker — each option id encodes the full productImage/price edits so a +// tap tells GPT exactly what to apply (see interactiveReply.buildValueEditId). +// Presented as a WhatsApp list (buttonText set) rather than reply buttons. +function selectTvModel(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, + })), + }; +} + +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)', + }; +} + +// What can be edited? — the menu is fixed and doesn't depend on document +// contents, so no Express API call is needed here. +async function checkAllowedEdits(image) { + return buildTopLevelEditOptions(image.id); +} + +// Apply edits via the real Adobe Express generate-variation pipeline. Returns a +// structured outcome — never sends the image itself — so the caller (app.js) can +// phrase the final reply (matching the user's language) and deliver the image +// with that phrasing as its caption in one message. +async function editGraphic(phoneNumber, image, edits, { sendText } = {}) { + 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 { 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: 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 }; + } + + if (typeof sendText === 'function') await sendText(phoneNumber, '⏳ Applying your edit and re-rendering with Adobe Express…'); + + 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 { status: 'api_error', productName: image.name, reason: 'generate_failed' }; + } + + recordEdits(phoneNumber, image.id, edits); + + return { status: 'success', productName: image.name, changes: edits, thumbnailUrl }; +} + +module.exports = { + selectTvModel, + checkAllowedEdits, + editGraphic, + buildTopLevelEditOptions, + MAX_DISCOUNT_PERCENT, +}; diff --git a/src/expressFlow.test.js b/src/expressFlow.test.js new file mode 100644 index 0000000000..8492d67a13 --- /dev/null +++ b/src/expressFlow.test.js @@ -0,0 +1,201 @@ +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.' }, + ], + }, + ], +}; + +const TV_ELEMENTS_DOC = { + documentPages: [ + { + pageNumber: 1, + taggedElements: [ + { name: 'productImage', type: 'image', value: '' }, + { name: 'oldPrice', type: 'text', value: '' }, + { name: 'price', type: 'text', value: '' }, + ], + }, + ], +}; + +// 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 returns the fixed Edit Product/Discount/Price menu, without calling the Express API', async () => { + expressApi.getTaggedDocument = async () => { throw new Error('should not be called'); }; + const image = catalogImage('phone-1'); + + const reply = await expressFlow.checkAllowedEdits(image); + + 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 } = expressFlow.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' }, + ] + ); +}); + +test('editGraphic returns a disallowed_fields status and makes no generate call for a field outside the tagged elements', async () => { + expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; + expressApi.generateVariation = async () => { + throw new Error('should not be called'); + }; + const image = catalogImage('phone-4'); + + const result = await expressFlow.editGraphic('phone-4', image, { background_color: 'red' }, {}); + + 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"/); +}); + +test('editGraphic applies an allowed edit end-to-end: generates, polls, and returns a success outcome with the thumbnail', 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 image = catalogImage('phone-5'); + + const result = await expressFlow.editGraphic('phone-5', image, { cta: '20% off' }, {}); + + assert.deepEqual(result, { + status: 'success', + productName: 'Croma Earbuds', + changes: { cta: '20% off' }, + thumbnailUrl: 'https://example.com/thumb.png', + }); + + const updated = findTrackedImage('phone-5', 'img_1'); + assert.deepEqual(updated.currentEdits, { cta: '20% off' }); +}); + +test('editGraphic returns an api_error/generate_failed status 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'); }; + const image = catalogImage('phone-6'); + + const result = await expressFlow.editGraphic('phone-6', image, { cta: '20% off' }, {}); + + assert.equal(result.status, 'api_error'); + assert.equal(result.reason, 'generate_failed'); + + const updated = findTrackedImage('phone-6', 'img_1'); + assert.deepEqual(updated.currentEdits, {}); +}); + +test('editGraphic returns an api_error/lookup_failed status when getTaggedDocument fails', async () => { + expressApi.getTaggedDocument = async () => { throw new Error('getTaggedDocument failed 500: boom'); }; + const image = catalogImage('phone-8'); + + const result = await expressFlow.editGraphic('phone-8', image, { cta: '20% off' }, {}); + + assert.equal(result.status, 'api_error'); + assert.equal(result.reason, 'lookup_failed'); +}); + +test('editGraphic 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 image = findTrackedImage('phone-9', 'img_2'); + + const result = await expressFlow.editGraphic('phone-9', image, { price: 17000 }, {}); + + assert.equal(result.status, 'discount_capped'); + assert.equal(result.maxPercent, 40); +}); + +test('editGraphic 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 image = findTrackedImage('phone-10', 'img_2'); + + const result = await expressFlow.editGraphic('phone-10', image, { price: 20399 }, {}); + + assert.equal(result.status, 'success'); + assert.deepEqual(result.changes, { price: 20399 }); +}); + +test('selectTvModel returns the 3 fixed TV model options with a list-picker body text and button', () => { + const result = expressFlow.selectTvModel('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'] + ); +}); + +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/imageStore.js b/src/imageStore.js new file mode 100644 index 0000000000..c9dd11ce65 --- /dev/null +++ b/src/imageStore.js @@ -0,0 +1,59 @@ +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(); + +// 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) { + 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}`) || {}, + })); +} + +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, createDesign }; diff --git a/src/imageStore.test.js b/src/imageStore.test.js new file mode 100644 index 0000000000..d15b37d2be --- /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', source: 'express', 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/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.' + ); +}); diff --git a/src/localFlow.js b/src/localFlow.js new file mode 100644 index 0000000000..afedeebf3a --- /dev/null +++ b/src/localFlow.js @@ -0,0 +1,197 @@ +// ── 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')); +} + +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, sendText }) { + const design = loadOnamDesign(); + const productList = Array.isArray(products) && products.length ? products.join(' + ') : (products || 'your products'); + 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 }); + + // 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' : ''; + // 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); + } catch (err) { + console.error('[localFlow.createDesign] sendImage error', { message: err.message }); + return `I built your ${festive} design, but couldn't send the image right now — try asking me to resend it.`; + } + + return { skipSend: true, historyText: caption }; +} + +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, sendText }) { + 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 }); + + // 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 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); + } catch (err) { + console.error('[localFlow.editGraphic] sendImage error', { imageId: image.id, message: err.message }); + return `I updated "${image.name}", but couldn't send the image right now — try asking me to resend it.`; + } + + return { skipSend: true, historyText: caption }; +} + +module.exports = { createDesign, checkAllowedEdits, editGraphic }; diff --git a/src/localFlow.test.js b/src/localFlow.test.js new file mode 100644 index 0000000000..ffbf2be065 --- /dev/null +++ b/src/localFlow.test.js @@ -0,0 +1,202 @@ +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 localFlow = require('./localFlow'); +const { findTrackedImage } = require('./imageStore'); + +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; +} + +// 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, caption) => sent.push({ link, caption }), sent }; +} + +// 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(); + 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 with a caption', async () => { + useFixture(); + const { sendImage, sent } = captureSendImage(); + + const reply = await localFlow.createDesign( + 'onam-phone-1', + { occasion: 'Onam', products: ['LG washing machine', 'dishwasher'], offer: '20% off' }, + { sendImage } + ); + + assert.equal(sent[0].link, FIXTURE.images.base); + assert.match(sent[0].caption, /Onam/); + assert.match(sent[0].caption, /20% off/); + 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 () => { + 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('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'); + + 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, c) => sent.push({ link: l, caption: c }) }); + + 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 () => { + 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, c) => sent.push({ link: l, caption: c }) }); + + assert.equal(sent.at(-1).link, FIXTURE.images.final); +}); + +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, c) => sent.push({ link: l, caption: c }) }); + + 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, c) => sent.push({ link: l, caption: c }) }); + + 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'); + + 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); +}); + +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, c) => sent.push({ link: l, caption: c }) }); + + 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'); + + 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); +}); + +test('editing a locked element is refused', async () => { + const { image } = await setup('onam-phone-5'); + + 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 () => { + const { image, sent } = await setup('onam-phone-6'); + + 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).link, FIXTURE.images.final); +}); diff --git a/src/metaUpload.js b/src/metaUpload.js new file mode 100644 index 0000000000..4a6a68402b --- /dev/null +++ b/src/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/src/metaUpload.test.js b/src/metaUpload.test.js new file mode 100644 index 0000000000..e4aed1b9ca --- /dev/null +++ b/src/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/yarn.lock b/yarn.lock index 35c2bede4d..e4212e3db7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4,570 +4,486 @@ accepts@^2.0.0: version "2.0.0" - resolved "https://registry.yarnpkg.com/accepts/-/accepts-2.0.0.tgz#bbcf4ba5075467f3f2131eab3cffc73c2f5d7895" + 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://registry.yarnpkg.com/array-flatten/-/array-flatten-3.0.0.tgz#6428ca2ee52c7b823192ec600fa3ed2f157cd541" - 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" - 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://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" + 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://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" - 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://registry.yarnpkg.com/content-disposition/-/content-disposition-1.0.0.tgz#844426cb398f934caefcbb172200126bc7ceace2" - 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.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://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" + resolved "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" 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== +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== cookie-signature@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.2.1.tgz#790dea2cce64638c7ae04d9fabed193bd7ccf3b4" - 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" - integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw== - -debug@2.6.9: - version "2.6.9" - resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" - 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" - integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== - dependencies: - ms "2.0.0" + version "1.2.2" + resolved "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz#57c7fc3cc293acab9fec54d73e15690ebe4a1793" + integrity sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg== -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" +cookie@^0.7.1: + version "0.7.2" + resolved "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" + integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== -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== +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: 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" - 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" - -depd@2.0.0: +depd@^2.0.0, depd@~2.0.0: version "2.0.0" - resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" + 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://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" - 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://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" - integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= + 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://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" + 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://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" - integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= - -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" - 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://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + 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://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" - integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= + 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://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" - integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= + 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://registry.yarnpkg.com/express/-/express-5.0.0.tgz#744f9ec86025a01aeca99e4300aa4fc050d493c7" - 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://registry.yarnpkg.com/finalhandler/-/finalhandler-2.0.0.tgz#9d3c79156dfa798069db7de7dd53bc37546f564b" - 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://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" + resolved "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== -fresh@2.0.0: +fresh@^2.0.0: version "2.0.0" - resolved "https://registry.yarnpkg.com/fresh/-/fresh-2.0.0.tgz#8dd7df6a1b3a1b3a5cf186c05a5dd267622635a4" + resolved "https://registry.npmjs.org/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" - integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== - 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://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://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz#e385f5a4b5227d449c3eabbad05494ef0abbeadd" - 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://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" - 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" - integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== + resolved "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== dependencies: - es-define-property "^1.0.0" + dunder-proto "^1.0.1" + es-object-atoms "^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" - 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://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" - 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://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" - 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://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" - 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://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.5.2.tgz#af6d628dccfb463b7364d97f715e4b74b8c8c2b8" - 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://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" - 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://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + 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://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" + 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://registry.yarnpkg.com/is-promise/-/is-promise-4.0.0.tgz#42ff9f84206c1991d26debf520dd5c01042dd2f3" + resolved "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz#42ff9f84206c1991d26debf520dd5c01042dd2f3" 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= +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://registry.yarnpkg.com/media-typer/-/media-typer-1.1.0.tgz#6ab74b8f2d3320f2064b2a87a38e7931ff3a5561" + resolved "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz#6ab74b8f2d3320f2064b2a87a38e7931ff3a5561" integrity sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw== 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://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://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" - integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= - -mime-db@1.40.0: - version "1.40.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.40.0.tgz#a65057e998db090f732a68f6c276d387d4126c32" - 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" - 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" - 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" - 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://registry.yarnpkg.com/mime-types/-/mime-types-2.1.24.tgz#b6f8d0b3e951efb77dedeca194cff6d16f676f81" - 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" - -ms@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" - integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= - -ms@2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" - integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + mime-db "^1.54.0" ms@^2.1.3: version "2.1.3" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + resolved "https://registry.npmjs.org/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://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://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.2.tgz#dea0088467fb991e67af4058147a24824a3043ff" - 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://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" + 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://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + resolved "https://registry.npmjs.org/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== dependencies: wrappy "1" -parseurl@^1.3.3, parseurl@~1.3.3: +openai@^6.45.0: + version "6.45.0" + resolved "https://registry.npmjs.org/openai/-/openai-6.45.0.tgz#53840c5c5848884dfbff47006f839b27d1d955b9" + integrity sha512-5DQVNErssk0afNpTTHUm/qZPU4iKR9OYdNid8Ib4puq4gHNNvGWZht2zY4h9a8JMF949Ik6m8gQutllVPbjdnw== + +parseurl@^1.3.3: version "1.3.3" - resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" + 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://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.1.0.tgz#4d687606ed0be8ed512ba802eb94d620cb1a86f0" - 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://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" + 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://registry.yarnpkg.com/qs/-/qs-6.13.0.tgz#6ca3bd58439f7e245655798997787b0d88a51906" - 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://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" - 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://registry.yarnpkg.com/raw-body/-/raw-body-3.0.0.tgz#25b3476f07a51600619dae3fe82ddc28a36e5e0f" - 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://registry.yarnpkg.com/router/-/router-2.0.0.tgz#8692720b95de83876870d7bc638dd3c7e1ae8a27" - 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://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" - 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://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + 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://registry.yarnpkg.com/send/-/send-1.1.0.tgz#4efe6ff3bb2139b0e5b2648d8b18d4dec48fc9c5" - 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://registry.yarnpkg.com/serve-static/-/serve-static-2.1.0.tgz#1b4eacbe93006b79054faa4d6d0a501d7f0e84e2" - 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://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" - 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://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" - 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://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" - 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://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" - 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://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" + 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://registry.yarnpkg.com/type-is/-/type-is-2.0.0.tgz#7d249c2e2af716665cc149575dadb8b3858653af" - 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://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" - 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://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" - integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= - -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://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== -vary@~1.1.2: +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://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://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + resolved "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==