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/offer-design.json b/data/offer-design.json new file mode 100644 index 0000000000..9f8fe947f6 --- /dev/null +++ b/data/offer-design.json @@ -0,0 +1,22 @@ +{ + "_comment": "Personalised customer offer used by create_design (source: 'local'). Two canned creatives — English and Hindi. Swap the image URLs for your hosted banners. Plans/featured drive the plan-picker buttons and the HQ price-floor governance in the system prompt.", + "images": { + "en": "https://s7ap1.scene7.com/is/image/varunAEM/banner-apoorva?cache=off", + "hi": "https://s7ap1.scene7.com/is/image/varunAEM/banner-apoorva-hindi?cache=off" + }, + "plans": ["3-Yr Comprehensive", "Zero Dep + RSA", "Engine Protect combo"], + "featured": { + "plan": "3-Yr Comprehensive", + "price": "₹28,999/year", + "was": "₹52,499/year", + "savings": "₹23,500/year" + }, + "defaults": { "customer": "Apoorva", "model": "Grand Vitara" }, + "models": ["Grand Vitara", "Brezza", "Swift", "Baleno", "Ertiga", "Fronx", "Jimny", "Dzire", "Ciaz", "XL6"], + "slots": { + "editable": [ + { "name": "language", "type": "text", "aliases": ["lang", "translate", "translation", "headline", "text"] } + ], + "locked": ["logo", "brand", "price", "plan", "product"] + } +} diff --git a/demo-auto-flow.sh b/demo-auto-flow.sh new file mode 100755 index 0000000000..94ad5a32de --- /dev/null +++ b/demo-auto-flow.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Drives the personalised automobile-insurance offer flow (Flow 2) against a +# running webhook, one step at a time. Watch the server logs for `GPT chose +# action:` and `[edit:local] resolved image`. +# +# Usage: bash demo-auto-flow.sh +# BASE_URL=http://localhost:3000 PHONE=919899860983 bash demo-auto-flow.sh + +set -euo pipefail + +BASE_URL="${BASE_URL:-http://localhost:3000}" +PHONE="${PHONE:-919899860983}" +PAUSE="${PAUSE:-10}" +GEN_PAUSE="${GEN_PAUSE:-30}" + +# Send a free-text WhatsApp message. +send() { + echo ">>> [text] $1" + curl -s -X POST "$BASE_URL/" -H 'Content-Type: application/json' -d "$(cat </dev/null + echo " (200 ack — check server logs)" + echo +} + +# Tap a reply button (WhatsApp interactive button_reply). Arg = button title. +tap() { + echo ">>> [tap button] $1" + curl -s -X POST "$BASE_URL/" -H 'Content-Type: application/json' -d "$(cat </dev/null + echo " (200 ack — check server logs)" + echo +} + +echo "=== WhatsCraft personalised auto-insurance offer flow → $BASE_URL (phone: $PHONE) ===" +echo + +# 1) Salesman asks for a personalised offer → WC asks which HQ-approved plan [buttons] +send "Apoorva test drove the Grand Vitara yesterday and asked for insurance options. Create a personalised offer for her." +sleep "$PAUSE" + +# 2) Pick the plan → WC asks whether to add the salesman's contact [Yes/No] +tap "3-Yr Comprehensive" +sleep "$PAUSE" + +# 3) Yes to contact → WC asks "anything else?" [Yes / No, go ahead] +tap "✅ Yes" +sleep "$PAUSE" + +# 4) Nothing else → WC streams progress for a few seconds, then sends the English banner. +# Wait for that generation to finish before the next step so messages don't interleave. +tap "➡️ No, go ahead" +echo "... waiting ${GEN_PAUSE}s for generation to stream + finish ..." +sleep "$GEN_PAUSE" + +# 5) Translate → WC streams briefly, then sends the Hindi banner +send "make it in Hindi" + +echo "=== done — verify the two banners (English + Hindi) on WhatsApp and the tool choices in the logs ===" 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..c4614b7e7d 100644 --- a/package.json +++ b/package.json @@ -2,15 +2,19 @@ "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" + "@aws-sdk/client-s3": "^3.1094.0", + "@aws-sdk/s3-request-presigner": "^3.1094.0", + "express": "^5.0.0", + "openai": "^6.45.0" } } diff --git a/render.yaml b/render.yaml index 9583d53df2..11984f61d0 100644 --- a/render.yaml +++ b/render.yaml @@ -4,7 +4,43 @@ 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 + - key: AWS_ACCESS_KEY_ID + sync: false + - key: AWS_SECRET_ACCESS_KEY + sync: false + - key: AWS_REGION + sync: false + - key: S3_BUCKET_NAME + sync: false diff --git a/src/actions.js b/src/actions.js new file mode 100644 index 0000000000..6dd62aca79 --- /dev/null +++ b/src/actions.js @@ -0,0 +1,89 @@ +// 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); +} + +// Flow 1 (express/catalog) — the fixed Diwali-offer caption used as the image +// message text after a successful edit_graphic on img_1. +function buildDiwaliOfferCaption(result) { + return expressFlow.buildDiwaliOfferCaption(result); +} + +// Flow 2 (local) — approved plans + governance for the GPT system prompt. +function getOfferContext() { + return localFlow.getOfferContext(); +} + +// 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, + buildDiwaliOfferCaption, + getOfferContext, +}; diff --git a/src/actions.test.js b/src/actions.test.js new file mode 100644 index 0000000000..4b63c16b53 --- /dev/null +++ b/src/actions.test.js @@ -0,0 +1,55 @@ +// 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 writeOfferFixture() { + const offer = { + images: { en: 'https://cdn.test/en.png', hi: 'https://cdn.test/hi.png' }, + plans: ['3-Yr Comprehensive'], + featured: { plan: '3-Yr Comprehensive', price: '₹28,999/year', was: '₹52,499/year', savings: '₹23,500/year' }, + slots: { editable: [{ name: 'language', type: 'text', aliases: ['translate'] }], locked: [] }, + }; + const p = path.join(os.tmpdir(), `offer-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + fs.writeFileSync(p, JSON.stringify(offer)); + process.env.OFFER_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 () => { + writeOfferFixture(); + const sent = []; + const sendImage = async (_to, link) => sent.push(link); + await actionCreateDesign('phone-r3', { customer: 'Apoorva', model: 'Grand Vitara', plan: '3-Yr Comprehensive' }, { sendImage }); + + const reply = await actionEditGraphic('phone-r3', 'local_1', { language: 'Hindi' }, { sendImage }); + + assert.match(reply.historyText, /anything else/i); + assert.equal(sent.at(-1), 'https://cdn.test/hi.png'); // canned Hindi URL — proves the local flow ran +}); diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000000..a9aa3a8415 --- /dev/null +++ b/src/app.js @@ -0,0 +1,585 @@ +const express = require('express'); +const OpenAI = require('openai'); +const { getTrackedImages } = require('./imageStore'); +const { + actionListCampaignGraphics, + actionCreateDesign, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, + actionSelectTvModel, + buildTopLevelEditOptions, + buildDiwaliOfferCaption, + getOfferContext, +} = require('./actions'); +const { parseEditOptionId, messageTextForInteractiveReply } = require('./interactiveReply'); +const flow2Session = require('./flow2Session'); + +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(); + +// Active Flow-2 gathering sessions, keyed by phone number. While a session is +// present, inbound messages are handled deterministically by the flow2Session +// state machine (plan → contact → confirm → create) and the LLM is bypassed — +// that's what makes the question sequence deterministic. See flow2Session.js. +const flow2Sessions = new Map(); + +// Flow 2 (the personalised-offer flow) is gated to a single demo phone number so +// arbitrary inbound numbers can't trigger it — everyone else is restricted to the +// existing behaviour (Flow 1 + generic tools). Substring match, env-overridable. +const FLOW2_PHONE = process.env.FLOW2_PHONE || '9899860983'; +function isFlow2Phone(phoneNumber) { + return String(phoneNumber).includes(FLOW2_PHONE); +} + +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; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// WhatsApp delivers link-image messages a beat after plain text/interactive +// messages (it has to fetch the image before it can display it), so awaiting +// sendImage() isn't enough to guarantee the image lands on-device before a +// message sent right after it. This pause gives the image a head start so the +// follow-up edit-menu buttons don't arrive first. +const IMAGE_DELIVERY_DELAY_MS = Number(process.env.IMAGE_DELIVERY_DELAY_MS ?? 1800); + +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); +} + +// ── Flow 2 deterministic sequence executor ─────────────────────────────────── + +// Execute a descriptor returned by flow2Session (ask/reask a question, or create +// the design). Keeps all the WhatsApp side effects here; flow2Session stays pure. +async function runFlow2Action(phoneNumber, action) { + if (!action) return; + if (action.type === 'ask' || action.type === 'reask') { + await sendQuickReplies(phoneNumber, action.question, action.options); + appendHistory(phoneNumber, 'assistant', action.question); + return; + } + if (action.type === 'create') { + console.log('[flow2] create_design (deterministic)', action.args); + const result = await actionCreateDesign(phoneNumber, action.args, { sendImage, sendText }); + if (typeof result === 'string') { + // Error path — the flow couldn't send the image, so surface the message. + await sendText(phoneNumber, result); + appendHistory(phoneNumber, 'assistant', result); + } else { + // Success — image + caption already sent by the flow. + appendHistory(phoneNumber, 'assistant', result.historyText); + } + } +} + +// Handle one inbound message while a Flow-2 session is active. Fully deterministic: +// no LLM call — the answer is fuzzy-matched and the state machine picks the next step. +async function handleFlow2Turn(phoneNumber, userText) { + const { plans } = getOfferContext(); + const session = flow2Sessions.get(phoneNumber); + const { state, action } = flow2Session.advance(session, userText, { plans }); + if (state) flow2Sessions.set(phoneNumber, state); + else flow2Sessions.delete(phoneNumber); + await runFlow2Action(phoneNumber, action); +} + +// ── 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: + 'Signal that the user wants a brand-new PERSONALISED offer creative for a specific customer (e.g. a personalised car-insurance offer for a customer who test drove a model). Call this as SOON as you recognise that intent, passing only customer and model. The system then gathers the plan and contact details with tappable buttons and drives the rest of the sequence — so do NOT pass plan or includeContact, and do NOT ask any questions first. Do NOT use this for bulk generation from a CSV/Excel file — that is generate_bulk_graphics.', + parameters: { + type: 'object', + properties: { + customer: { type: 'string', description: "The customer's name, e.g. \"Apoorva\"" }, + model: { type: 'string', description: "The vehicle/product the customer is interested in, e.g. \"Grand Vitara\"" }, + }, + required: [], + }, + }, + }, + { + 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'); + + // Note: the personalised-offer plan picker is no longer driven from this prompt — + // the deterministic Flow-2 state machine (flow2Session.js) asks for the plan with + // tappable buttons after create_design fires, so the plans list isn't needed here. + + 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 personalised customer offer (create_design) — this is for a car-dealership salesman making an on-brand offer to send to a specific customer (e.g. "Apoorva test drove the Grand Vitara and asked about insurance — make her a personalised offer"). The message may contain typos. +- As SOON as you recognise this intent, call create_design immediately, passing only the customer's name and the model you can extract from the conversation. If either is unclear, still call create_design with whatever you have (or leave it blank). +- Do NOT ask about the plan, the contact, or "anything else" yourself, and do NOT call ask_for_more_information for this flow — the system gathers the plan and contact with tappable buttons and drives the rest of the sequence deterministically after create_design is called. Never pass plan or includeContact yourself. +- To translate the offer to another language (e.g. "make it in Hindi"), call edit_graphic — the offer is available in English and Hindi. +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 { "price": , "discountPercentage": "%" } — 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); + + // Flow 2 is gated to the demo phone. On that phone, a fresh "create a + // personalised offer" message (fuzzy-matched, typo-tolerant) (re)starts the + // gathering session from step 1 — checked BEFORE the in-progress bypass so a + // re-prompt always restarts rather than being read as an answer. + if (isFlow2Phone(phoneNumber) && flow2Session.isCreateOfferIntent(userText)) { + const { plans, defaults, models } = getOfferContext(); + const offerArgs = flow2Session.extractOffer(userText, { defaults, models }); + const { state, action } = flow2Session.start(offerArgs, { plans }); + flow2Sessions.set(phoneNumber, state); + console.log('[flow2] (re)start via fuzzy trigger', offerArgs); + await runFlow2Action(phoneNumber, action); + continue; + } + + // Flow-2 gathering in progress → drive it deterministically, skip the LLM. + if (flow2Sessions.has(phoneNumber)) { + await handleFlow2Turn(phoneNumber, userText); + continue; + } + + 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': { + // Fallback Flow-2 entry: the deterministic fuzzy trigger above catches + // most create requests before the LLM runs, but this handles phrasings it + // missed. Gated to the demo phone — other numbers are restricted to the + // current behaviour, so a stray create_design there is politely declined. + if (!isFlow2Phone(phoneNumber)) { + replyText = "I can help you update your existing campaign graphics — tell me what you'd like to change."; + break; + } + // The LLM only DETECTS intent + extracts customer/model; it never creates + // directly. Starting a session hands sequencing to the deterministic state + // machine (plan → contact → confirm, fuzzy-matched) before create_design + // ever runs — the model can't skip the questions. + const { plans, defaults } = getOfferContext(); + const startArgs = { customer: args.customer || defaults.customer, model: args.model || defaults.model }; + const { state, action } = flow2Session.start(startArgs, { plans }); + flow2Sessions.set(phoneNumber, state); + await runFlow2Action(phoneNumber, action); + 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. On success, + // the image is delivered with the fixed Diwali-offer caption (updated + // price baked in); other statuses are phrased to match the user's + // language. Either way, the fixed Edit Product/Discount/Price menu + // follows — after a short pause so it can't arrive before the image. + const result = await actionEditGraphic(phoneNumber, args.image_id, args.edits, { sendImage, sendText }); + if (typeof result === 'string') { + replyText = result; + } else if (result.status) { + if (result.status === 'success') { + replyText = buildDiwaliOfferCaption(result); + try { + await sendImage(phoneNumber, result.thumbnailUrl, replyText); + await sleep(IMAGE_DELIVERY_DELAY_MS); + } 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 { + replyText = await phraseOutcome(phoneNumber, userText, result); + 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..365468ac90 --- /dev/null +++ b/src/editOptions.js @@ -0,0 +1,56 @@ +// 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; +} + +// Matches the emoji-prefixed style already used by the express flow's fixed +// Edit Product/Discount/Price menu (see expressFlow.TOP_LEVEL_EDIT_FIELDS). +const FIELD_EMOJIS = { + language: '🌐', + headline: '📝', + background: '🎨', + address: '📍', + offer: '🏷️', +}; + +function emojiForField(name) { + return FIELD_EMOJIS[name] || '✏️'; +} + +function buildEditOptions(elements, imageId) { + return elements.map((element) => ({ + id: `edit:${imageId}:${element.name}`, + title: truncateTitle(`${emojiForField(element.name)} 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/express/s3Upload.js b/src/express/s3Upload.js new file mode 100644 index 0000000000..2e297ac2c6 --- /dev/null +++ b/src/express/s3Upload.js @@ -0,0 +1,56 @@ +// Downloads a source image and re-hosts it on S3, returning a short-lived +// presigned GET URL. Adobe's generate-variation API only accepts S3, Dropbox, +// or Azure URLs for image tagMappings (see VariationDetails.tagMappings in the +// Express API spec) — CDNs like Scene7 are rejected — so any source image +// coming from elsewhere must be proxied through S3 first. +// +// Ported from the upload_from_url flow in dynamicmedia-autoreflow's +// ImageS3Uploader (Python, git.corp.adobe.com/CQ/dynamicmedia-autoreflow) to +// the AWS SDK for JS v3. +const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3'); +const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); + +const DEFAULT_EXPIRES_IN_SECONDS = 1800; // 30 min, matches the Python default +const CACHE_SAFETY_MARGIN_SECONDS = 60; + +const s3Client = new S3Client({ region: process.env.AWS_REGION || 'us-east-1' }); + +// imageUrl+s3Key -> { url, expiresAt } — avoids re-downloading/re-uploading +// and re-signing on every call for a URL that's still valid. +const presignedUrlCache = new Map(); + +function s3KeyFromUrl(imageUrl) { + const { pathname } = new URL(imageUrl); + return pathname.split('/').filter(Boolean).pop() || 'image.jpg'; +} + +async function uploadFromUrl(imageUrl, { s3Key, expiresInSeconds = DEFAULT_EXPIRES_IN_SECONDS } = {}) { + const bucket = process.env.S3_BUCKET_NAME; + const key = s3Key || s3KeyFromUrl(imageUrl); + const cacheKey = `${imageUrl}:${key}`; + + const cached = presignedUrlCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return cached.url; + } + + const response = await fetch(imageUrl); + if (!response.ok) { + throw new Error(`Failed to download image ${imageUrl}: ${response.status}`); + } + const body = Buffer.from(await response.arrayBuffer()); + const contentType = response.headers.get('content-type') || 'image/jpeg'; + + await s3Client.send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: body, ContentType: contentType })); + + const presignedUrl = await getSignedUrl(s3Client, new GetObjectCommand({ Bucket: bucket, Key: key }), { + expiresIn: expiresInSeconds, + }); + + const cacheTtlMs = Math.max(expiresInSeconds - CACHE_SAFETY_MARGIN_SECONDS, CACHE_SAFETY_MARGIN_SECONDS) * 1000; + presignedUrlCache.set(cacheKey, { url: presignedUrl, expiresAt: Date.now() + cacheTtlMs }); + + return presignedUrl; +} + +module.exports = { uploadFromUrl }; diff --git a/src/expressFlow.js b/src/expressFlow.js new file mode 100644 index 0000000000..55f1d6df96 --- /dev/null +++ b/src/expressFlow.js @@ -0,0 +1,296 @@ +// ── 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 s3Upload = require('./express/s3Upload'); +const { buildValueEditId } = require('./interactiveReply'); +const { formatAllowedEdits } = require('./editOptions'); + +// A "change the product to a TV" request offers 3 fixed models as quick replies. +// +// The source product photo lives on Adobe's Scene7 CDN, but Adobe's own +// generate-variation API rejects scene7.com for image tagMappings — only AWS S3, +// Dropbox, or Azure (windows.net) URLs are accepted (see VariationDetails.tagMappings +// in the Express API spec). So it's re-hosted on S3 (upload + presigned GET URL, +// via s3Upload.uploadFromUrl) at edit time instead of a one-off hand-uploaded URL +// that itself expires and has to be manually regenerated. +const TV_PRODUCT_SOURCE_IMAGE_URL = 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000&fmt=png-alpha'; +const TV_MODEL_TITLES = ['Sony Bravia K-75', 'LG UA82 AI', 'Samsung UA4']; + +// The presigned S3 URL is long — WhatsApp interactive list rows cap `id` at 200 +// chars (#131009 "Row id is too long"), and buildValueEditId round-trips the full +// edits object through both the row id and (via GPT) the synthetic message text. +// So the *encoded* edits carry this short token instead of the real URL; it's +// expanded to a fresh presigned S3 URL in editGraphic before anything is sent to +// the Express API. +const TV_PLACEHOLDER_IMAGE_TOKEN = 'tv-model-image-placeholder'; +const TV_MODEL_EDITS = { productImage: TV_PLACEHOLDER_IMAGE_TOKEN, oldPrice: 33999, price: 27199 }; + +// TEMP: product-change requests ("select_tv_model" / any edit carrying a +// productImage) are served from this pre-rendered banner instead of the real +// Adobe Express generate-variation pipeline — the user still sees the usual +// "calling Express API" progress message, but no Express/S3 network calls happen. +const HARDCODED_PRODUCT_UPDATE_THUMBNAIL_URL = + 'https://s7ap1.scene7.com/is/image/healthmonitor/Croma-Diwali-Banner-product-update?wid=1000'; + +// TEMP: discount-change requests are served from this pre-rendered banner instead +// of the real Adobe Express pipeline. GPT sends a "discountPercentage" key (see the +// system prompt in app.js) alongside the computed price, but that key isn't one of +// the document's tagged elements — the real pipeline would always reject it as a +// disallowed field — so it's routed here instead, same as the product-change bypass. +const HARDCODED_DISCOUNT_UPDATE_THUMBNAIL_URL = + 'https://s7ap1.scene7.com/is/image/healthmonitor/Croma-Diwali-Banner-discount-update?wid=1000'; + +async function expandPlaceholderEdits(edits) { + if (edits && edits.productImage === TV_PLACEHOLDER_IMAGE_TOKEN) { + const signedUrl = await s3Upload.uploadFromUrl(TV_PRODUCT_SOURCE_IMAGE_URL); + return { ...edits, productImage: signedUrl }; + } + return edits; +} + +function formatINR(amount) { + return Number(amount).toLocaleString('en-IN'); +} + +// The success caption for img_1 (Croma Diwali offer) — a fixed festive template +// rather than a GPT-phrased one-liner, so the banner's own promo copy carries +// through into the message text. Falls back to the TV_MODEL_EDITS constants for +// price/oldPrice when an edit (e.g. a lone "change price" request) hasn't gone +// through selectTvModel, so both are always populated. +function buildDiwaliOfferCaption({ price, oldPrice } = {}) { + const displayPrice = formatINR(price ?? TV_MODEL_EDITS.price); + const displayOldPrice = formatINR(oldPrice ?? TV_MODEL_EDITS.oldPrice); + + return `🪔✨ DIWALI DHAMAKA OFFER! ✨🪔 + +🎉 Upgrade your viewing experience this festive season with an amazing deal on the Sony Bravia K-75! + +💥 Special Festive Price: ₹${displayPrice} +Regular Price: ₹${displayOldPrice} + +✅ Trusted Sony Quality +✅ Limited Period Diwali Offer +✅ Great Savings for Your Family + +📞 Contact us today or visit our store before the offer ends! + +🎁 Hurry! Stocks are limited. Grab this festive deal now! 🛍️✨`; +} + +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). All 3 +// models share the same placeholder edits, so the title is passed as a discriminator +// to keep the 3 row ids unique — WhatsApp rejects list messages with duplicate row ids. +// 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), + 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 } = {}) { + const isProductUpdate = Boolean(edits && Object.prototype.hasOwnProperty.call(edits, 'productImage')); + const isDiscountUpdate = Boolean(edits && Object.prototype.hasOwnProperty.call(edits, 'discountPercentage')); + + if (isProductUpdate) { + if (typeof sendText === 'function') await sendText(phoneNumber, '⏳ Applying your edit and re-rendering with Adobe Express…'); + + const mergedEdits = { ...image.currentEdits, ...edits }; + recordEdits(phoneNumber, image.id, edits); + + const outcome = { status: 'success', productName: image.name, changes: edits, thumbnailUrl: HARDCODED_PRODUCT_UPDATE_THUMBNAIL_URL }; + if (mergedEdits.price !== undefined) outcome.price = mergedEdits.price; + if (mergedEdits.oldPrice !== undefined) outcome.oldPrice = mergedEdits.oldPrice; + return outcome; + } + + if (isDiscountUpdate) { + // discountPercentage isn't a tagged document field (only productImage/oldPrice/ + // price are) — drop it before recording so it doesn't taint currentEdits for a + // later real edit's tagMappings. + const { discountPercentage, ...taggedEdits } = edits; + + const requestedPercent = parsePercent(discountPercentage); + if (requestedPercent !== null && requestedPercent > MAX_DISCOUNT_PERCENT) { + 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 mergedEdits = { ...image.currentEdits, ...taggedEdits }; + recordEdits(phoneNumber, image.id, taggedEdits); + + const outcome = { status: 'success', productName: image.name, changes: taggedEdits, thumbnailUrl: HARDCODED_DISCOUNT_UPDATE_THUMBNAIL_URL }; + if (mergedEdits.price !== undefined) outcome.price = mergedEdits.price; + if (mergedEdits.oldPrice !== undefined) outcome.oldPrice = mergedEdits.oldPrice; + return outcome; + } + + edits = await expandPlaceholderEdits(edits); + + 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); + + // Tagged text elements always hold string values (see getTaggedDocument), and + // Adobe's generate-variation API rejects a JSON number for a text tag with + // "Unsupported text value for tag: " (422) — so numeric edits like price/ + // oldPrice must be sent as strings even though they're computed as numbers. + const tagMappings = Object.fromEntries( + Object.entries(mergedEdits).map(([key, value]) => [key, String(value)]) + ); + + let thumbnailUrl; + try { + const { statusUrl } = await expressApi.generateVariation(image.docId, tagMappings, 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); + + const outcome = { status: 'success', productName: image.name, changes: edits, thumbnailUrl }; + if (mergedEdits.price !== undefined) outcome.price = mergedEdits.price; + if (mergedEdits.oldPrice !== undefined) outcome.oldPrice = mergedEdits.oldPrice; + return outcome; +} + +module.exports = { + selectTvModel, + checkAllowedEdits, + editGraphic, + buildTopLevelEditOptions, + buildDiwaliOfferCaption, + MAX_DISCOUNT_PERCENT, + TV_PRODUCT_SOURCE_IMAGE_URL, + TV_PLACEHOLDER_IMAGE_TOKEN, + HARDCODED_PRODUCT_UPDATE_THUMBNAIL_URL, + HARDCODED_DISCOUNT_UPDATE_THUMBNAIL_URL, +}; diff --git a/src/expressFlow.test.js b/src/expressFlow.test.js new file mode 100644 index 0000000000..cc8da5cffa --- /dev/null +++ b/src/expressFlow.test.js @@ -0,0 +1,304 @@ +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 s3Upload = require('./express/s3Upload'); +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 serves a product-change request from the hardcoded banner and makes no Express/S3 calls', async () => { + s3Upload.uploadFromUrl = async () => { throw new Error('should not be called'); }; + expressApi.getTaggedDocument = async () => { throw new Error('should not be called'); }; + expressApi.generateVariation = async () => { throw new Error('should not be called'); }; + const image = catalogImage('phone-tv'); + const sentTexts = []; + + const edits = { productImage: expressFlow.TV_PLACEHOLDER_IMAGE_TOKEN, oldPrice: '33999', price: '27199' }; + const result = await expressFlow.editGraphic('phone-tv', image, edits, { + sendText: async (phone, text) => sentTexts.push([phone, text]), + }); + + assert.deepEqual(result, { + status: 'success', + productName: 'Croma Earbuds', + changes: edits, + thumbnailUrl: expressFlow.HARDCODED_PRODUCT_UPDATE_THUMBNAIL_URL, + price: '27199', + oldPrice: '33999', + }); + assert.deepEqual(sentTexts, [['phone-tv', '⏳ Applying your edit and re-rendering with Adobe Express…']]); + + const updated = findTrackedImage('phone-tv', 'img_1'); + assert.equal(updated.currentEdits.productImage, expressFlow.TV_PLACEHOLDER_IMAGE_TOKEN); +}); + +test('editGraphic serves a discount-change request from the hardcoded banner and makes no Express calls, stripping discountPercentage before recording', async () => { + expressApi.getTaggedDocument = async () => { throw new Error('should not be called'); }; + expressApi.generateVariation = async () => { throw new Error('should not be called'); }; + const image = catalogImage('phone-discount'); + const sentTexts = []; + + const edits = { price: 20399, discountPercentage: '40%' }; + const result = await expressFlow.editGraphic('phone-discount', image, edits, { + sendText: async (phone, text) => sentTexts.push([phone, text]), + }); + + assert.deepEqual(result, { + status: 'success', + productName: 'Croma Earbuds', + changes: { price: 20399 }, + thumbnailUrl: expressFlow.HARDCODED_DISCOUNT_UPDATE_THUMBNAIL_URL, + price: 20399, + }); + assert.deepEqual(sentTexts, [['phone-discount', '⏳ Applying your edit and re-rendering with Adobe Express…']]); + + const updated = findTrackedImage('phone-discount', 'img_1'); + assert.deepEqual(updated.currentEdits, { price: 20399 }); + assert.ok(!('discountPercentage' in updated.currentEdits)); +}); + +test('editGraphic caps a discount-change request above 40% without calling Express', async () => { + expressApi.getTaggedDocument = async () => { throw new Error('should not be called'); }; + expressApi.generateVariation = async () => { throw new Error('should not be called'); }; + const image = catalogImage('phone-discount-2'); + + const result = await expressFlow.editGraphic( + 'phone-discount-2', + image, + { price: 16999, discountPercentage: '50%' }, + {} + ); + + assert.deepEqual(result, { status: 'discount_capped', productName: 'Croma Earbuds', maxPercent: 40 }); + + const updated = findTrackedImage('phone-discount-2', 'img_1'); + assert.deepEqual(updated.currentEdits, {}); +}); + +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('editGraphic stringifies numeric edit values before calling generate-variation (Adobe rejects a JSON number for a text tag with "Unsupported text value")', async () => { + writeFixtureCatalog([{ id: 'img_3', name: 'TV Product', docId: 'urn:doc:3' }]); + expressApi.getTaggedDocument = async () => TV_ELEMENTS_DOC; + expressApi.generateVariation = async (docId, tagMappings) => { + assert.deepEqual(tagMappings, { productImage: 'https://example.com/tv.png', oldPrice: '33999', price: '30000' }); + for (const value of Object.values(tagMappings)) assert.equal(typeof value, 'string'); + return { jobId: 'job-3', statusUrl: 'https://express-api.adobe.io/status/job-3' }; + }; + expressApi.pollJobStatus = async () => ({ status: 'succeeded', document: { thumbnailUrl: 'https://example.com/thumb3.png' } }); + recordEdits('phone-11', 'img_3', { productImage: 'https://example.com/tv.png', oldPrice: 33999, price: 27199 }); + const image = findTrackedImage('phone-11', 'img_3'); + + const result = await expressFlow.editGraphic('phone-11', image, { price: 30000 }, {}); + + assert.equal(result.status, 'success'); + assert.equal(result.price, 30000); // outcome keeps the numeric value for caption formatting +}); + +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 gives every option a unique id (WhatsApp list rows must not repeat ids)', () => { + const result = expressFlow.selectTvModel('img_1'); + + const ids = result.options.map((option) => option.id); + assert.equal(new Set(ids).size, ids.length); +}); + +test('selectTvModel keeps every option id within WhatsApp\'s 200-char row id limit (#131009)', () => { + const result = expressFlow.selectTvModel('img_1'); + + for (const option of result.options) { + assert.ok(option.id.length <= 200, `id too long (${option.id.length}): ${option.id}`); + } +}); + +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: expressFlow.TV_PLACEHOLDER_IMAGE_TOKEN, + oldPrice: 33999, + price: 27199, + }, + }); + } +}); diff --git a/src/flow2Session.js b/src/flow2Session.js new file mode 100644 index 0000000000..64fd43d014 --- /dev/null +++ b/src/flow2Session.js @@ -0,0 +1,166 @@ +// ── Flow 2: deterministic gathering sequence ───────────────────────────────── +// The plan → contact → confirm → create sequence is driven HERE, in code — not by +// the LLM. Previously the LLM was asked to volunteer these questions before +// create_design, which it did inconsistently (a complete-sounding opening message +// made it skip straight to creating). Now the LLM's only job for this flow is to +// detect the intent and extract customer/model; once that fires, this state machine +// owns every subsequent step, so the questions ALWAYS get asked, in order. +// +// Pure logic: start()/advance() take the current session + the user's (fuzzy-matched) +// answer and return { state, action }. `state` is the next session state (null when +// the sequence is complete); `action` is a descriptor the caller executes: +// { type: 'ask' | 'reask', question, options } → send tappable buttons +// { type: 'create', args: { customer, model, plan, includeContact } } → build it +// The caller never has to know the step order. + +const { matchOption, matchYesNo, normalize, fuzzyEq } = require('./fuzzy'); + +// ── Deterministic "create a personalised offer" intent detection ───────────── +// Detects the Flow-2 trigger from free text WITHOUT the LLM, tolerant of typos +// ("crate a personalsied bannr"), so the gated demo phone can (re)start the +// session on a matching message. Kept distinct from Flow-1 edit phrasing: the +// trigger needs an explicit "make/create/design …" + "banner/offer/creative", +// whereas Flow-1 edits say "change/edit the price/discount/product". +const ACTION_WORDS = ['create', 'make', 'design', 'generate', 'build', 'prepare', 'craft']; +const ARTIFACT_WORDS = ['banner', 'offer', 'creative', 'poster', 'graphic', 'flyer', 'design']; + +function isCreateOfferIntent(text) { + const toks = normalize(text).split(' ').filter(Boolean); + if (toks.length === 0) return false; + const hasAction = toks.some((t) => ACTION_WORDS.some((a) => fuzzyEq(t, a))); + const hasArtifact = toks.some((t) => ARTIFACT_WORDS.some((a) => fuzzyEq(t, a))); + const hasPersonalised = toks.some((t) => fuzzyEq(t, 'personalised') || fuzzyEq(t, 'personalized')); + return (hasAction && hasArtifact) || (hasPersonalised && hasArtifact); +} + +// Words that can look like a name but never are (sentence starters / verbs). +const NOT_A_NAME = new Set([ + 'i', 'she', 'he', 'they', 'we', 'you', 'the', 'a', 'an', 'my', 'her', 'his', 'their', + 'make', 'create', 'design', 'help', 'please', 'hi', 'hello', 'hey', 'can', 'could', + 'would', 'need', 'want', 'also', 'and', 'but', 'so', 'yesterday', 'today', +]); + +function extractCustomer(text, fallback) { + const capitalised = String(text).match(/\b[A-Z][a-z]+\b/g) || []; + for (const w of capitalised) { + if (!NOT_A_NAME.has(w.toLowerCase())) return w; + } + return fallback || null; +} + +function extractModel(text, models, fallback) { + const n = normalize(text); + for (const m of models) { + const nm = normalize(m); + if (!nm) continue; + if (n.includes(nm)) return m; // full name present, e.g. "grand vitara" + const words = nm.split(' '); + const key = words[words.length - 1]; // distinctive last word, e.g. "vitara" + if (key.length >= 3 && n.split(' ').some((t) => fuzzyEq(t, key))) return m; + } + return fallback || null; +} + +// Best-effort deterministic extraction of customer + model from the trigger text, +// falling back to the configured demo defaults when nothing is found. +function extractOffer(text, { defaults = {}, models = [] } = {}) { + return { + customer: extractCustomer(text, defaults.customer), + model: extractModel(text, models, defaults.model), + }; +} + +const STEP = { + PLAN: 'awaiting_plan', + CONTACT: 'awaiting_contact', + CONFIRM: 'awaiting_confirm', +}; + +const YES_NO = ['✅ Yes', '🙅 No']; +const CONFIRM_OPTIONS = ['✅ Yes', '➡️ No, go ahead']; + +function who(state) { + return state.customer || 'your customer'; +} + +function askPlan(plans) { + return { type: 'ask', step: STEP.PLAN, question: 'Which HQ-approved plan should I feature?', options: plans }; +} + +function askContact(state) { + return { + type: 'ask', + step: STEP.CONTACT, + question: `Should I add your name & number so ${who(state)} can reach you directly?`, + options: YES_NO, + }; +} + +function askConfirm() { + return { + type: 'ask', + step: STEP.CONFIRM, + question: "Anything else you'd like to add before I create it?", + options: CONFIRM_OPTIONS, + }; +} + +// Begin a session from the LLM-extracted customer/model. First question is the plan. +function start({ customer, model } = {}, { plans } = {}) { + const state = { + step: STEP.PLAN, + customer: customer || null, + model: model || null, + plan: null, + includeContact: null, + }; + return { state, action: askPlan(plans) }; +} + +// Advance the session by one user answer. Fuzzy-matches the answer for the current +// step; on a confident match it moves forward, otherwise it re-asks the same +// question (never silently misfires). +function advance(state, userText, { plans } = {}) { + switch (state.step) { + case STEP.PLAN: { + const plan = matchOption(userText, plans); + if (!plan) { + return { state, action: { type: 'reask', step: STEP.PLAN, question: "Sorry, I didn't catch that — please pick a plan:", options: plans } }; + } + const next = { ...state, plan, step: STEP.CONTACT }; + return { state: next, action: askContact(next) }; + } + + case STEP.CONTACT: { + const yn = matchYesNo(userText); + if (yn === null) { + return { state, action: { type: 'reask', step: STEP.CONTACT, question: 'Just tap Yes or No — add your name & number?', options: YES_NO } }; + } + const next = { ...state, includeContact: yn === 'yes', step: STEP.CONFIRM }; + return { state: next, action: askConfirm() }; + } + + case STEP.CONFIRM: { + const yn = matchYesNo(userText); + if (yn === null) { + return { state, action: { type: 'reask', step: STEP.CONFIRM, question: 'Tap "No, go ahead" to create it now, or "Yes" if you want to add something.', options: CONFIRM_OPTIONS } }; + } + // Either answer proceeds to creation: the canned banner has nothing further to + // gather, and the success caption already invites post-creation edits ("Want to + // change anything?"). The question is kept for the scripted demo feel. + return { + state: null, + action: { + type: 'create', + args: { customer: state.customer, model: state.model, plan: state.plan, includeContact: state.includeContact }, + }, + }; + } + + default: + // Unknown step — abandon the session rather than loop. + return { state: null, action: null }; + } +} + +module.exports = { start, advance, STEP, isCreateOfferIntent, extractOffer }; diff --git a/src/flow2Session.test.js b/src/flow2Session.test.js new file mode 100644 index 0000000000..7916fedd22 --- /dev/null +++ b/src/flow2Session.test.js @@ -0,0 +1,116 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const flow2Session = require('./flow2Session'); + +const PLANS = ['3-Yr Comprehensive', 'Zero Dep + RSA', 'Engine Protect combo']; +const ctx = { plans: PLANS }; + +test('start asks the plan first, carrying customer/model into state', () => { + const { state, action } = flow2Session.start({ customer: 'Apoorva', model: 'Grand Vitara' }, ctx); + assert.equal(state.step, 'awaiting_plan'); + assert.equal(state.customer, 'Apoorva'); + assert.equal(state.model, 'Grand Vitara'); + assert.equal(action.type, 'ask'); + assert.deepEqual(action.options, PLANS); +}); + +test('full happy path: plan → contact → confirm → create (deterministic order)', () => { + let { state, action } = flow2Session.start({ customer: 'Apoorva', model: 'Grand Vitara' }, ctx); + assert.equal(action.step, 'awaiting_plan'); + + // plan (typed with a typo) + ({ state, action } = flow2Session.advance(state, 'comprehesive', ctx)); + assert.equal(state.step, 'awaiting_contact'); + assert.equal(state.plan, '3-Yr Comprehensive'); + assert.match(action.question, /reach you directly/); + + // contact (emoji button tap) + ({ state, action } = flow2Session.advance(state, '✅ Yes', ctx)); + assert.equal(state.step, 'awaiting_confirm'); + assert.equal(state.includeContact, true); + + // confirm → create + ({ state, action } = flow2Session.advance(state, '➡️ No, go ahead', ctx)); + assert.equal(state, null, 'session complete'); + assert.equal(action.type, 'create'); + assert.deepEqual(action.args, { + customer: 'Apoorva', + model: 'Grand Vitara', + plan: '3-Yr Comprehensive', + includeContact: true, + }); +}); + +test('contact answer "No" sets includeContact false', () => { + let { state } = flow2Session.start({ customer: 'A', model: 'M' }, ctx); + ({ state } = flow2Session.advance(state, '3-Yr Comprehensive', ctx)); + const { state: next } = flow2Session.advance(state, 'nope', ctx); + assert.equal(next.includeContact, false); +}); + +test('unmatched plan re-asks the same step without advancing', () => { + const { state } = flow2Session.start({ customer: 'A', model: 'M' }, ctx); + const { state: next, action } = flow2Session.advance(state, 'zzz gibberish', ctx); + assert.equal(next.step, 'awaiting_plan', 'stays on plan step'); + assert.equal(action.type, 'reask'); +}); + +test('unmatched contact answer re-asks, does not assume', () => { + let { state } = flow2Session.start({ customer: 'A', model: 'M' }, ctx); + ({ state } = flow2Session.advance(state, 'Zero Dep + RSA', ctx)); + const { state: next, action } = flow2Session.advance(state, 'hmm not sure', ctx); + assert.equal(next.step, 'awaiting_contact'); + assert.equal(action.type, 'reask'); +}); + +test('isCreateOfferIntent detects the trigger, including with typos', () => { + assert.equal( + flow2Session.isCreateOfferIntent('Apoorva visited my store yesterday and took a vitara test drive. she asked for insurance offers. help me crate a personalsied banner for her'), + true + ); + assert.equal(flow2Session.isCreateOfferIntent('make a personalised offer for Priya'), true); + assert.equal(flow2Session.isCreateOfferIntent('design a banner'), true); +}); + +test('isCreateOfferIntent does NOT fire on Flow-1 edits or post-create replies', () => { + assert.equal(flow2Session.isCreateOfferIntent('change the discount to 40%'), false); + assert.equal(flow2Session.isCreateOfferIntent('what can I edit'), false); + assert.equal(flow2Session.isCreateOfferIntent('make it in Hindi'), false); + assert.equal(flow2Session.isCreateOfferIntent('3-Yr Comprehensive'), false); + assert.equal(flow2Session.isCreateOfferIntent('✅ Yes'), false); +}); + +const MODELS = ['Grand Vitara', 'Brezza', 'Swift']; +const DEFAULTS = { customer: 'Apoorva', model: 'Grand Vitara' }; + +test('extractOffer pulls customer + model from the trigger text', () => { + const out = flow2Session.extractOffer( + 'Priya took a Brezza test drive, make her a personalised banner', + { defaults: DEFAULTS, models: MODELS } + ); + assert.equal(out.customer, 'Priya'); + assert.equal(out.model, 'Brezza'); +}); + +test('extractOffer resolves a distinctive model word ("vitara" → "Grand Vitara")', () => { + const out = flow2Session.extractOffer('Apoorva test drove the vitara', { defaults: DEFAULTS, models: MODELS }); + assert.equal(out.customer, 'Apoorva'); + assert.equal(out.model, 'Grand Vitara'); +}); + +test('extractOffer falls back to defaults when nothing is found', () => { + const out = flow2Session.extractOffer('make me a personalised banner', { defaults: DEFAULTS, models: MODELS }); + assert.equal(out.customer, 'Apoorva'); + assert.equal(out.model, 'Grand Vitara'); +}); + +test('confirm proceeds to create on either recognised answer', () => { + // "Yes, I have something to add" still creates (canned banner; edits are post-create) + let { state } = flow2Session.start({ customer: 'A', model: 'M' }, ctx); + ({ state } = flow2Session.advance(state, 'Engine Protect combo', ctx)); + ({ state } = flow2Session.advance(state, 'No', ctx)); + const { state: done, action } = flow2Session.advance(state, '✅ Yes', ctx); + assert.equal(done, null); + assert.equal(action.type, 'create'); + assert.equal(action.args.plan, 'Engine Protect combo'); +}); diff --git a/src/fuzzy.js b/src/fuzzy.js new file mode 100644 index 0000000000..20507fb7db --- /dev/null +++ b/src/fuzzy.js @@ -0,0 +1,120 @@ +// Typo-tolerant matching for the deterministic Flow-2 gathering sequence +// (see flow2Session.js). The salesman may TAP a button (exact title flows back) +// or TYPE a reply with mistakes ("comprehesive", "zero dep rsa", "yess", "nope") — +// both must resolve to the same canonical answer without any LLM call, so the +// step sequencing stays deterministic. + +// Classic Levenshtein edit distance (iterative two-row DP). +function levenshtein(a, b) { + a = String(a); + b = String(b); + if (a === b) return 0; + if (a.length === 0) return b.length; + if (b.length === 0) return a.length; + + let prev = Array.from({ length: b.length + 1 }, (_, i) => i); + let curr = new Array(b.length + 1); + for (let i = 1; i <= a.length; i++) { + curr[0] = i; + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost); + } + [prev, curr] = [curr, prev]; + } + return prev[b.length]; +} + +// Lowercase, strip punctuation/emoji to spaces, collapse whitespace. This is what +// makes an emoji-prefixed button title ("✅ Yes", "Zero Dep + RSA") comparable to +// a plain typed answer. +function normalize(s) { + return String(s) + .toLowerCase() + .replace(/[^a-z0-9\s]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +// Two tokens are "close enough" if their edit distance is a small fraction of the +// longer token — ~1 typo per 3 chars. +function fuzzyEq(a, b, maxRatio = 0.34) { + if (a === b) return true; + const d = levenshtein(a, b); + return d / Math.max(a.length, b.length) <= maxRatio; +} + +const SIGNIFICANT_MIN_LEN = 3; + +function significantTokens(normalized) { + return normalized.split(' ').filter((t) => t.length >= SIGNIFICANT_MIN_LEN); +} + +// Resolve a free-text answer to one of a closed set of options (e.g. the HQ plans), +// tolerating typos, partial phrases, and distinctive-word shorthand. Returns the +// matched option string, or null when nothing is confidently close. +function matchOption(input, options) { + const ni = normalize(input); + if (!ni) return null; + + // 1) exact (covers a button tap, whose title flows back verbatim) + for (const o of options) if (normalize(o) === ni) return o; + + // 2) full-phrase containment either direction ("engine protect combo" vs a + // longer title, or a typed superset of a short option) + for (const o of options) { + const no = normalize(o); + if (ni.length >= SIGNIFICANT_MIN_LEN && (no.includes(ni) || ni.includes(no))) return o; + } + + // 3) a distinctive word that uniquely identifies one option ("comprehensive", + // "rsa", "engine") — even with a typo in that word. + const itoks = significantTokens(ni); + for (const it of itoks) { + const owners = options.filter((o) => + significantTokens(normalize(o)).some((ot) => fuzzyEq(it, ot)) + ); + if (owners.length === 1) return owners[0]; + } + + // 4) fall back to majority token overlap (at least half an option's significant + // words fuzzy-matched by the input). + let best = null; + let bestScore = 0; + for (const o of options) { + const otoks = significantTokens(normalize(o)); + if (otoks.length === 0) continue; + let hits = 0; + for (const ot of otoks) if (itoks.some((it) => fuzzyEq(it, ot))) hits++; + const score = hits / otoks.length; + if (score > bestScore) { + bestScore = score; + best = o; + } + } + return bestScore >= 0.5 ? best : null; +} + +const YES_WORDS = ['yes', 'y', 'ya', 'yaa', 'yah', 'yeah', 'yep', 'yup', 'sure', 'ok', 'okay', 'okey', 'haan', 'han', 'ha', 'add', 'yess', 'yesss']; +const NO_WORDS = ['no', 'n', 'nope', 'nah', 'na', 'naa', 'skip', 'dont']; +// Phrases that mean "stop asking, just create it" — treated as a 'no' (nothing to add). +const PROCEED_PHRASES = ['go ahead', 'goahead', 'go head', 'proceed', 'create it', 'make it', 'nothing', 'no thanks', 'all good', 'looks good', 'ready']; + +// Resolve a free-text answer to 'yes' | 'no' | null (ambiguous / unrecognised). +// Typo-tolerant and emoji-safe. Callers decide what yes/no means per question. +function matchYesNo(input) { + const n = normalize(input); + if (!n) return null; + + if (PROCEED_PHRASES.some((p) => n.includes(p))) return 'no'; + + const toks = n.split(' '); + const isYes = toks.some((t) => YES_WORDS.includes(t) || fuzzyEq(t, 'yes') || fuzzyEq(t, 'yeah') || fuzzyEq(t, 'sure')); + const isNo = toks.some((t) => NO_WORDS.includes(t) || fuzzyEq(t, 'no', 0.5) || fuzzyEq(t, 'nope')); + + if (isYes && !isNo) return 'yes'; + if (isNo && !isYes) return 'no'; + return null; +} + +module.exports = { levenshtein, normalize, fuzzyEq, matchOption, matchYesNo }; diff --git a/src/fuzzy.test.js b/src/fuzzy.test.js new file mode 100644 index 0000000000..e287864aac --- /dev/null +++ b/src/fuzzy.test.js @@ -0,0 +1,55 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { levenshtein, normalize, matchOption, matchYesNo } = require('./fuzzy'); + +const PLANS = ['3-Yr Comprehensive', 'Zero Dep + RSA', 'Engine Protect combo']; + +test('levenshtein basic distances', () => { + assert.equal(levenshtein('kitten', 'kitten'), 0); + assert.equal(levenshtein('kitten', 'sitten'), 1); + assert.equal(levenshtein('', 'abc'), 3); +}); + +test('normalize strips emoji, punctuation and case', () => { + assert.equal(normalize('✅ Yes'), 'yes'); + assert.equal(normalize('Zero Dep + RSA'), 'zero dep rsa'); + assert.equal(normalize('➡️ No, go ahead'), 'no go ahead'); +}); + +test('matchOption resolves an exact button-tap title', () => { + assert.equal(matchOption('3-Yr Comprehensive', PLANS), '3-Yr Comprehensive'); + assert.equal(matchOption('Engine Protect combo', PLANS), 'Engine Protect combo'); +}); + +test('matchOption tolerates typos in the distinctive word', () => { + assert.equal(matchOption('comprehesive', PLANS), '3-Yr Comprehensive'); + assert.equal(matchOption('engin protect', PLANS), 'Engine Protect combo'); +}); + +test('matchOption handles partial / shorthand answers', () => { + assert.equal(matchOption('zero dep rsa', PLANS), 'Zero Dep + RSA'); + assert.equal(matchOption('rsa', PLANS), 'Zero Dep + RSA'); + assert.equal(matchOption('comprehensive', PLANS), '3-Yr Comprehensive'); +}); + +test('matchOption returns null when nothing is close', () => { + assert.equal(matchOption('make it in hindi', PLANS), null); + assert.equal(matchOption('', PLANS), null); +}); + +test('matchYesNo reads yes variants incl. emoji and typos', () => { + for (const s of ['✅ Yes', 'yes', 'yess', 'yeah', 'ya', 'sure', 'haan', 'ok']) { + assert.equal(matchYesNo(s), 'yes', `expected yes for "${s}"`); + } +}); + +test('matchYesNo reads no variants incl. emoji and proceed phrases', () => { + for (const s of ['🙅 No', 'no', 'nope', 'nah', '➡️ No, go ahead', 'go ahead', 'proceed', 'nothing else']) { + assert.equal(matchYesNo(s), 'no', `expected no for "${s}"`); + } +}); + +test('matchYesNo returns null when unrecognised', () => { + assert.equal(matchYesNo('maybe later'), null); + assert.equal(matchYesNo(''), null); +}); 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..09d0548fbf --- /dev/null +++ b/src/interactiveReply.js @@ -0,0 +1,57 @@ +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. +// +// An optional discriminator can be inserted before the encoded edits (imageId:discriminator:json) +// so that multiple options sharing the exact same edits (e.g. selectTvModel's 3 models, which all +// apply the same placeholder productImage/price) still get distinct ids — WhatsApp list rows must +// have unique ids or the whole message is rejected (400 #131009 "Duplicated row id"). The +// discriminator itself carries no meaning to the parser; it's discarded on parse. +function buildValueEditId(imageId, edits, discriminator) { + const encoded = encodeURIComponent(JSON.stringify(edits)); + return discriminator === undefined + ? `${EDIT_ID_PREFIX}${imageId}:${encoded}` + : `${EDIT_ID_PREFIX}${imageId}:${discriminator}:${encoded}`; +} + +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); + // The encoded edits/field segment is always the LAST colon-separated part (encodeURIComponent + // never leaves a raw ':' in it), so a discriminator inserted in between is simply skipped over. + const remainder = rest.slice(rest.lastIndexOf(':') + 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..b6c3c8cf57 --- /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&fmt=png-alpha', 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..5a9f7d9085 --- /dev/null +++ b/src/localFlow.js @@ -0,0 +1,141 @@ +// ── Flow 2: canned personalised customer offers (image.source === 'local') ──── +// A car-dealership salesman creates a personalised, on-brand offer to send to a +// specific customer (e.g. an insurance offer for someone who test drove a model). +// No Adobe Express calls — the creative resolves to pre-hosted canned image URLs +// (English / Hindi) in data/offer-design.json. Self-contained: never touches the +// express flow. Governance (no below-floor pricing) is enforced in the GPT prompt. + +const fs = require('node:fs'); +const path = require('node:path'); +const { recordEdits, createDesign: registerDesign } = require('./imageStore'); +const { buildEditOptions, formatAllowedEdits } = require('./editOptions'); + +function loadOfferDesign() { + const filePath = process.env.OFFER_DESIGN_FILE || path.join(__dirname, '..', 'data', 'offer-design.json'); + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +// Approved plans + featured governance details, plus the fallback customer/model +// and known-model list used by the deterministic Flow-2 trigger (flow2Session.js). +function getOfferContext() { + const design = loadOfferDesign(); + return { + plans: design.plans || [], + featured: design.featured || null, + defaults: design.defaults || {}, + models: design.models || [], + }; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Stream progress messages one at a time with a pause between them, so generation +// feels like real work. No-op without a sender (keeps unit tests fast). Tunable +// via GEN_STEP_DELAY_MS. +async function streamProgress(sendText, phoneNumber, messages) { + if (typeof sendText !== 'function') return; + const delay = Number(process.env.GEN_STEP_DELAY_MS ?? 1500); + for (const message of messages) { + await sendText(phoneNumber, message); + await sleep(delay); + } +} + +function hasHindi(value) { + return /[ऀ-ॿ]/.test(String(value)); +} + +// A translation request: the model may pass Devanagari text or the word "Hindi" +// under any key. Detect either so "make it in Hindi" (however phrased) always maps +// to the Hindi creative. +function wantsHindi(rawEdits) { + const text = Object.entries(rawEdits || {}).flat().map(String); + return text.some(hasHindi) || text.some((s) => /\bhindi\b/i.test(s)); +} + +function localEditElements(image) { + return (image.design.slots?.editable || []).map((slot) => ({ + name: slot.name, + type: slot.type || 'text', + value: image.currentEdits[slot.name] ?? '', + })); +} + +// create_design: register a personalised offer and send the English creative, +// streaming progress first so it feels generated rather than instant. +async function createDesign(phoneNumber, { customer, model, plan, includeContact } = {}, { sendImage, sendText }) { + const design = loadOfferDesign(); + const who = customer || 'your customer'; + const car = model || 'their vehicle'; + const name = `${car} offer for ${who}`; + const image = registerDesign(phoneNumber, { name, design }); + + const steps = [ + `🎨 Creating ${who}'s personalised offer…`, + '📦 Pulling Maruti Suzuki Arena branding & approved colours…', + `📱 Adding the ${car} with the approved ${plan || 'insurance'} plan…`, + ]; + if (includeContact) steps.push('📍 Personalising it and adding your contact…'); + await streamProgress(sendText, phoneNumber, steps); + + const contactText = includeContact ? ' with your contact' : ''; + const caption = `Here you go 🚗 ${who}'s personalised ${car} offer${contactText} — on-brand and ready to forward.\n\nWant to change anything?`; + + console.log('[action:create_design]', { phoneNumber, customer, model, plan, includeContact, image: design.images.en }); + try { + await sendImage(phoneNumber, design.images.en, caption); + } catch (err) { + console.error('[localFlow.createDesign] sendImage error', { message: err.message }); + return `I created ${who}'s offer, but couldn't send the image right now — try asking me to resend it.`; + } + + return { skipSend: true, historyText: caption }; +} + +// Apply an edit. The only visual edit on this canned offer is the language +// (English ↔ Hindi); brand, price and plan are HQ-locked, so any other edit just +// re-sends the current creative. Below-floor pricing is refused upstream in the +// GPT prompt, so it never reaches here. +async function editGraphic(phoneNumber, image, rawEdits, { sendImage, sendText }) { + const design = image.design; + const toHindi = wantsHindi(rawEdits); + const language = toHindi ? 'Hindi' : (image.currentEdits.language || 'English'); + + recordEdits(phoneNumber, image.id, { language }); + const imageUrl = language === 'Hindi' ? design.images.hi : design.images.en; + console.log('[edit:local] resolved image', { imageId: image.id, language, imageUrl }); + + const progress = toHindi + ? ['🌸 Translating your banner to Hindi…', '✍️ Re-rendering with the Hindi text…'] + : ['✍️ Updating your creative…']; + await streamProgress(sendText, phoneNumber, progress); + + const summary = toHindi + ? '🌸 Here you go — your banner is now in Hindi!' + : "✅ Done — here's your updated banner."; + 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 }; +} + +// 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), + }; +} + +module.exports = { createDesign, editGraphic, checkAllowedEdits, getOfferContext }; diff --git a/src/localFlow.test.js b/src/localFlow.test.js new file mode 100644 index 0000000000..d56b9de132 --- /dev/null +++ b/src/localFlow.test.js @@ -0,0 +1,133 @@ +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: { + en: 'https://cdn.test/banner-en.png', + hi: 'https://cdn.test/banner-hi.png', + }, + plans: ['3-Yr Comprehensive', 'Zero Dep + RSA', 'Engine Protect combo'], + featured: { plan: '3-Yr Comprehensive', price: '₹28,999/year', was: '₹52,499/year', savings: '₹23,500/year' }, + slots: { + editable: [{ name: 'language', type: 'text', aliases: ['lang', 'translate', 'headline'] }], + locked: ['logo', 'price', 'plan'], + }, +}; + +function useFixture() { + const p = path.join(os.tmpdir(), `offer-design-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + fs.writeFileSync(p, JSON.stringify(FIXTURE)); + process.env.OFFER_DESIGN_FILE = p; +} + +// Captures both the image link and its caption. +function captureSendImage() { + const sent = []; + return { sendImage: async (_to, link, caption) => sent.push({ link, caption }), sent }; +} + +// Create an offer for a phone (English image sent) and return its tracked image. +async function setup(phone, args = { customer: 'Apoorva', model: 'Grand Vitara', plan: '3-Yr Comprehensive' }) { + useFixture(); + const { sendImage, sent } = captureSendImage(); + await localFlow.createDesign(phone, args, { sendImage }); + return { image: findTrackedImage(phone, 'local_1'), sendImage, sent }; +} + +test('createDesign sends the English creative with a personalised caption', async () => { + useFixture(); + const { sendImage, sent } = captureSendImage(); + + const reply = await localFlow.createDesign( + 'auto-1', + { customer: 'Apoorva', model: 'Grand Vitara', plan: '3-Yr Comprehensive', includeContact: true }, + { sendImage } + ); + + assert.equal(sent[0].link, FIXTURE.images.en); + assert.match(sent[0].caption, /Apoorva/); + assert.match(sent[0].caption, /Grand Vitara/); + assert.match(sent[0].caption, /change anything/i); + assert.equal(reply.skipSend, true); +}); + +test('createDesign streams progress (naming the model + contact) before the image', async () => { + useFixture(); + process.env.GEN_STEP_DELAY_MS = '0'; // no real pause in tests + const texts = []; + const { sendImage, sent } = captureSendImage(); + + await localFlow.createDesign( + 'auto-2', + { customer: 'Apoorva', model: 'Grand Vitara', plan: '3-Yr Comprehensive', includeContact: true }, + { sendImage, sendText: async (_t, m) => texts.push(m) } + ); + + assert.ok(texts.length >= 4, 'streams several progress messages'); + assert.ok(texts.some((m) => /Grand Vitara/.test(m)), 'names the model'); + assert.ok(texts.some((m) => /contact/i.test(m)), 'mentions the contact step'); + assert.equal(sent[0].link, FIXTURE.images.en); +}); + +test('editGraphic translates to the Hindi creative when Devanagari text is passed', async () => { + const { image, sent } = await setup('auto-3'); + + await localFlow.editGraphic('auto-3', image, { headline: 'नमस्ते अपूर्वा' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); + + assert.equal(sent.at(-1).link, FIXTURE.images.hi); + assert.match(sent.at(-1).caption, /Hindi/); +}); + +test('editGraphic translates to Hindi when the word "Hindi" is used under any key', async () => { + const { image, sent } = await setup('auto-4'); + + await localFlow.editGraphic('auto-4', image, { language: 'Hindi' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); + + assert.equal(sent.at(-1).link, FIXTURE.images.hi); +}); + +test('editGraphic streams Hindi progress before sending the Hindi image', async () => { + process.env.GEN_STEP_DELAY_MS = '0'; + const { image } = await setup('auto-5'); + const texts = []; + const sent = []; + + await localFlow.editGraphic('auto-5', image, { language: 'Hindi' }, { + sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }), + sendText: async (_t, m) => texts.push(m), + }); + + assert.ok(texts.some((m) => /Hindi/i.test(m))); + assert.equal(sent.at(-1).link, FIXTURE.images.hi); +}); + +test('editGraphic keeps the English creative for a non-language edit', async () => { + const { image, sent } = await setup('auto-6'); + + await localFlow.editGraphic('auto-6', image, { note: 'add urgency' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); + + assert.equal(sent.at(-1).link, FIXTURE.images.en); +}); + +test('checkAllowedEdits lists the editable slots from the schema', async () => { + const { image } = await setup('auto-7'); + + const result = localFlow.checkAllowedEdits(image); + + assert.equal(result.type, 'edit_options'); + assert.deepEqual(result.options.map((o) => o.title), ['🌐 Change language']); +}); + +test('getOfferContext exposes the approved plans and featured governance', () => { + useFixture(); + + const ctx = localFlow.getOfferContext(); + + assert.deepEqual(ctx.plans, FIXTURE.plans); + assert.equal(ctx.featured.price, '₹28,999/year'); +}); 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..fa1dde1ea1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,572 +2,780 @@ # yarn lockfile v1 +"@aws-sdk/checksums@^3.1000.19": + version "3.1000.19" + resolved "https://registry.yarnpkg.com/@aws-sdk/checksums/-/checksums-3.1000.19.tgz#91d29b5fd576a42547c6da587541759a5b4981d3" + integrity sha512-Hc4N100RdkuWshKBnhPzmpdftfi9mCLz+OHFELHM1QIgMH4QRUUWyWgfiebta/YX2Bd62wTcm3EqAP8TeXv0gA== + dependencies: + "@aws-sdk/core" "^3.976.0" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/client-s3@^3.1094.0": + version "3.1094.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-s3/-/client-s3-3.1094.0.tgz#3cfdc562da7330785bf3a2ea8f16b29650e7e33b" + integrity sha512-Qkz3HXW9bBajTO1Pgvbxkj2THliDnYPFBaJwIF1mDmnPe1E7reV9V/QJxBz14slIvdcU0tiyB+z37Qns0EY9Zg== + dependencies: + "@aws-sdk/checksums" "^3.1000.19" + "@aws-sdk/core" "^3.976.0" + "@aws-sdk/credential-provider-node" "^3.972.71" + "@aws-sdk/middleware-sdk-s3" "^3.972.65" + "@aws-sdk/signature-v4-multi-region" "^3.996.41" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/fetch-http-handler" "^5.6.6" + "@smithy/node-http-handler" "^4.9.6" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/core@^3.976.0": + version "3.976.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/core/-/core-3.976.0.tgz#c30d080b4b2ea8e22c07d9d5338c0331050f92da" + integrity sha512-0cjRaEdlVoOrsNb9pP5q1Syyc8pXw5xSj2Np2ryReRTr9FppIIRVSdZK4lbnfmc2Hvgux/xBOUU6baB7z8//uA== + dependencies: + "@aws-sdk/types" "^3.974.2" + "@aws-sdk/xml-builder" "^3.972.36" + "@aws/lambda-invoke-store" "^0.3.0" + "@smithy/core" "^3.29.4" + "@smithy/signature-v4" "^5.6.5" + "@smithy/types" "^4.16.1" + bowser "^2.11.0" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-env@^3.972.60": + version "3.972.60" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.60.tgz#84fc3b6c950834ccbb84b4b508a4e79027495f46" + integrity sha512-BAkxdoe7tpDDqCghGpuOeHQRbm/2znVvOQm0AvpQbA2tbfMN46doN4zx65fv85ImP3KADwc2zQPmbrlI9MPfMg== + dependencies: + "@aws-sdk/core" "^3.976.0" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-http@^3.972.62": + version "3.972.62" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.62.tgz#2104724d4f7ba0838aed0be86a19a2bdc92aee47" + integrity sha512-g/0fGqKTb9xpKdd9AtpmV5Eo3DFKbnkpA2+w0peISSlu7NfAoWOuYBFxsu+yWBtxU89ka55ezoZBCbFaS8pjYQ== + dependencies: + "@aws-sdk/core" "^3.976.0" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/fetch-http-handler" "^5.6.6" + "@smithy/node-http-handler" "^4.9.6" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-ini@^3.973.5": + version "3.973.5" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.5.tgz#83bab3633491e5b54d08e49e242f831a07aa3080" + integrity sha512-ylubazcRfq2TVus/qXucSXeC42Qdjp5HQxTu68K/BsdMiZlcSLD1zkpoCgApXZX1Y6YJhtGGs7ZHhO/GuIgBlw== + dependencies: + "@aws-sdk/core" "^3.976.0" + "@aws-sdk/credential-provider-env" "^3.972.60" + "@aws-sdk/credential-provider-http" "^3.972.62" + "@aws-sdk/credential-provider-login" "^3.972.67" + "@aws-sdk/credential-provider-process" "^3.972.60" + "@aws-sdk/credential-provider-sso" "^3.973.4" + "@aws-sdk/credential-provider-web-identity" "^3.972.66" + "@aws-sdk/nested-clients" "^3.997.34" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/credential-provider-imds" "^4.4.9" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-login@^3.972.67": + version "3.972.67" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.67.tgz#7d0cf698c69f5130a4c6c5adb4450f52d81e15a9" + integrity sha512-CCygIKJ9YbI3n84OClSaSppkgKKHVj2TGT33c6FRORZrYNZQ1POmD+ip0FLYokiJAK7sSdc3YVkOsBm90oxWMQ== + dependencies: + "@aws-sdk/core" "^3.976.0" + "@aws-sdk/nested-clients" "^3.997.34" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-node@^3.972.71": + version "3.972.71" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.71.tgz#d87fe6da001575845e6332ad118f8f721dfebf52" + integrity sha512-HIg7Q2osBzajQwL+1Vkyh2E7Gim3eTNb9RHIsOxDGjW0eZg4oEKtRs5sioCnc73ilhaOm4gX2lHVF8J7+nt2rg== + dependencies: + "@aws-sdk/credential-provider-env" "^3.972.60" + "@aws-sdk/credential-provider-http" "^3.972.62" + "@aws-sdk/credential-provider-ini" "^3.973.5" + "@aws-sdk/credential-provider-process" "^3.972.60" + "@aws-sdk/credential-provider-sso" "^3.973.4" + "@aws-sdk/credential-provider-web-identity" "^3.972.66" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/credential-provider-imds" "^4.4.9" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-process@^3.972.60": + version "3.972.60" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.60.tgz#528c752fb31016568fb81b7c05b345d41c611d31" + integrity sha512-YIo3f99hM43QdYG8hDzwGemnR/pU95b0kramqSJUTleCqaB7+HwKf7YZFHqvOgTqZTPx/mRmNIqoDRr3U0Z3Tw== + dependencies: + "@aws-sdk/core" "^3.976.0" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-sso@^3.973.4": + version "3.973.4" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.4.tgz#e6e3cdb687e4acfd7ab228c73102c05e08fb988d" + integrity sha512-BPdmL8sSBOCv4ngZ+3LHxyc3CNqDCEK37CHioCk7zGrTMY5sUtkH8q+o6qA80nn6w3/fyBPGNE7OIRlmoOxRQA== + dependencies: + "@aws-sdk/core" "^3.976.0" + "@aws-sdk/nested-clients" "^3.997.34" + "@aws-sdk/token-providers" "3.1092.0" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-web-identity@^3.972.66": + version "3.972.66" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.66.tgz#31d0c8b061edc5a87aa6f624dbaac2eb479a6639" + integrity sha512-kSAziJboOmZmsR9/MTbiNjowl2BPes1bQuJpne4qAZ62ubi8fjfr/aupJSQje6udBoYxXTQbsL0e0kby2la3ng== + dependencies: + "@aws-sdk/core" "^3.976.0" + "@aws-sdk/nested-clients" "^3.997.34" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/middleware-sdk-s3@^3.972.65": + version "3.972.65" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.65.tgz#9b49df5deb68dee5d5da4d630a6ce3676408de5e" + integrity sha512-udwNhRfDTfCB98mAHjjgsnKQlxygB4e0X+Obne/XjJpvVsF0YCQC8ZErd/8Z6IPoLQjtiKHzwqEDbZiLrJEnOg== + dependencies: + "@aws-sdk/core" "^3.976.0" + "@aws-sdk/signature-v4-multi-region" "^3.996.41" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/nested-clients@^3.997.34": + version "3.997.34" + resolved "https://registry.yarnpkg.com/@aws-sdk/nested-clients/-/nested-clients-3.997.34.tgz#bd371ab54f97b6d24d464743e4deb82403d6b890" + integrity sha512-Y9REVrSwmLM+Qy6sZJ7ofMC2S3Hr3tPP/4CzL5U1olPP7OGoF+6+Px0E49cVQBtSxJtyeLJMf0UaBErfeSahAA== + dependencies: + "@aws-sdk/core" "^3.976.0" + "@aws-sdk/signature-v4-multi-region" "^3.996.41" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/fetch-http-handler" "^5.6.6" + "@smithy/node-http-handler" "^4.9.6" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/s3-request-presigner@^3.1094.0": + version "3.1094.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1094.0.tgz#480b21fe5073cc6e08516ddc6898cd3c0cdd8b6b" + integrity sha512-7kG3C37tPWEpz8TyHslFhi2TrQ10und7ijerO0V/Ljq2uxBWVi4SFcol8yp2gM09HYzMnkWCfOxAKJZ2OZvClw== + dependencies: + "@aws-sdk/core" "^3.976.0" + "@aws-sdk/signature-v4-multi-region" "^3.996.41" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/signature-v4-multi-region@^3.996.41": + version "3.996.41" + resolved "https://registry.yarnpkg.com/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.41.tgz#1ac6743366e2382b73ed14af4b08d21de0bf1ed9" + integrity sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng== + dependencies: + "@aws-sdk/types" "^3.974.2" + "@smithy/signature-v4" "^5.6.5" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/token-providers@3.1092.0": + version "3.1092.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/token-providers/-/token-providers-3.1092.0.tgz#3178452622c2cac79ed4a0c77733a0edaf9e6b76" + integrity sha512-hBYUAr6iBLNFcsiWTgtBb0stdSw39VOUq4Sp4A5caCNf66BAZplWN4FleKrVpJx5li2YgdnK2DqoFSMWC642FQ== + dependencies: + "@aws-sdk/core" "^3.976.0" + "@aws-sdk/nested-clients" "^3.997.34" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/types@^3.974.2": + version "3.974.2" + resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.974.2.tgz#05e7ccac417735e0786d430d2d5cc139047a08da" + integrity sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA== + dependencies: + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/xml-builder@^3.972.36": + version "3.972.36" + resolved "https://registry.yarnpkg.com/@aws-sdk/xml-builder/-/xml-builder-3.972.36.tgz#966c17ded23b970b5e41cbab5d1abf85a304b99e" + integrity sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA== + dependencies: + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws/lambda-invoke-store@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz#708802d987f8e17bdf4af4de1031660ce1cdd65f" + integrity sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ== + +"@smithy/core@^3.29.4", "@smithy/core@^3.29.8": + version "3.29.8" + resolved "https://registry.yarnpkg.com/@smithy/core/-/core-3.29.8.tgz#6466029ff27285257cf9bb4864fdb632c870ccf0" + integrity sha512-rpCbCV+TimOBi3VLNBMmtTvgfOWcFIEAru3+TFlG87SL2F+te4jOnnNR+cf3uR4eJ5Qf4LnT80fqnBKgPRS6zA== + dependencies: + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@smithy/credential-provider-imds@^4.4.9": + version "4.4.13" + resolved "https://registry.yarnpkg.com/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.13.tgz#33dce458f03ee3a262b5379eff37e335eaea70a9" + integrity sha512-X+2HNZhWi5i3rJsCas0LPf6fTQUaKyJ40zd8aTO/bwpRfpU3biYaqLr7C1WMibL7PVKJalpi1PyybjGPNoHC8Q== + dependencies: + "@smithy/core" "^3.29.8" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@smithy/fetch-http-handler@^5.6.6": + version "5.6.10" + resolved "https://registry.yarnpkg.com/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.10.tgz#a50908b47d16a8f5c56e98762c2e27339a8c19ab" + integrity sha512-5/Yj9mS2JjTsB3B8ZX7euh77mrY9aXW23ag1yAmFykSRmA6vldqBrgqmSeQ50EjY+5SB8+aE4w14B6LKbBVEhQ== + dependencies: + "@smithy/core" "^3.29.8" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@smithy/node-http-handler@^4.9.6": + version "4.9.10" + resolved "https://registry.yarnpkg.com/@smithy/node-http-handler/-/node-http-handler-4.9.10.tgz#e03a874620cec491f787787b8bf6072d45adabc7" + integrity sha512-ETQz9v/Z+nTQc6fRWTXxUpxJqwpmzB3Tn3WKAdHwWkeT+m+HE5czs6GNG8vW+4vyxXSls65RVcvOZwk7Q/PS/Q== + dependencies: + "@smithy/core" "^3.29.8" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@smithy/signature-v4@^5.6.5": + version "5.6.9" + resolved "https://registry.yarnpkg.com/@smithy/signature-v4/-/signature-v4-5.6.9.tgz#09ede51303925677a401f9759b18041ff46a1070" + integrity sha512-g5rnEii/mkT0mjVJmlsaOfyNBtHNTecD9Lo4NP8D5HzMUEnZNpz7/FbvBCjNcV4vteHFAxOGiLUYNxPkDZZAPw== + dependencies: + "@smithy/core" "^3.29.8" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@smithy/types@^4.16.1": + version "4.16.1" + resolved "https://registry.yarnpkg.com/@smithy/types/-/types-4.16.1.tgz#19e199c234829a51c085caf63f0bb17bb80187e4" + integrity sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg== + dependencies: + tslib "^2.6.2" + 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.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 "^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" -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== - 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: +bowser@^2.11.0: + version "2.14.1" + resolved "https://registry.yarnpkg.com/bowser/-/bowser-2.14.1.tgz#4ea39bf31e305184522d7ad7bfd91389e4f0cb79" + integrity sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg== + +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== - 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" + 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 "^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== + resolved "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== dependencies: - get-intrinsic "^1.1.3" + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" -has-property-descriptors@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" - integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== - dependencies: - es-define-property "^1.0.0" - -has-proto@^1.0.1: - version "1.0.3" - resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.3.tgz#b31ddfe9b0e6e9914536a6ab286426d0214f77fd" - 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.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-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== +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.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-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== - 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== +tslib@^2.6.2: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + +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==