From 442473837f522107ff7e420581f7fccee86466e1 Mon Sep 17 00:00:00 2001 From: Max Edell Date: Fri, 17 Jul 2026 15:26:48 -0700 Subject: [PATCH] feat: recipe-notify action --- .github/workflows/deploy_prod.yml | 5 +- .github/workflows/deploy_stage.yml | 3 + README.md | 72 ++++++++ app.config.yaml | 48 ++++++ example.env | 16 +- src/actions/recipe-notify/detect.js | 48 ++++++ src/actions/recipe-notify/index.js | 115 +++++++++++++ src/actions/recipe-notify/links.js | 80 +++++++++ src/actions/recipe-notify/notify.js | 125 ++++++++++++++ src/actions/recipe-notify/recipes.js | 86 ++++++++++ src/actions/recipe-notify/state.js | 98 +++++++++++ src/actions/recipe-notify/sync.js | 168 +++++++++++++++++++ src/emails.js | 99 +++++++++++ test/recipe-notify/gate.test.js | 45 +++++ test/recipe-notify/recipe-notify.test.js | 204 +++++++++++++++++++++++ 15 files changed, 1210 insertions(+), 2 deletions(-) create mode 100644 src/actions/recipe-notify/detect.js create mode 100644 src/actions/recipe-notify/index.js create mode 100644 src/actions/recipe-notify/links.js create mode 100644 src/actions/recipe-notify/notify.js create mode 100644 src/actions/recipe-notify/recipes.js create mode 100644 src/actions/recipe-notify/state.js create mode 100644 src/actions/recipe-notify/sync.js create mode 100644 test/recipe-notify/gate.test.js create mode 100644 test/recipe-notify/recipe-notify.test.js diff --git a/.github/workflows/deploy_prod.yml b/.github/workflows/deploy_prod.yml index 7810a53..2b58cbd 100644 --- a/.github/workflows/deploy_prod.yml +++ b/.github/workflows/deploy_prod.yml @@ -80,4 +80,7 @@ jobs: EBS_JSON_API_KEY: ${{ secrets.EBS_JSON_API_KEY_PROD }} EBS_JSON_API_KEY_STAGE: ${{ secrets.EBS_JSON_API_KEY_STAGE }} EDGE_COMMERCE_API_BASE: ${{ secrets.EDGE_COMMERCE_API_BASE_PROD }} - EDGE_COMMERCE_API_BASE_STAGE: ${{ secrets.EDGE_COMMERCE_API_BASE_STAGE }} \ No newline at end of file + EDGE_COMMERCE_API_BASE_STAGE: ${{ secrets.EDGE_COMMERCE_API_BASE_STAGE }} + # recipe-notify — PROD is the only environment that sends the digest + RECIPE_NOTIFY_ENABLED: "true" + RECIPE_NOTIFY_TOKEN: ${{ secrets.RECIPE_NOTIFY_TOKEN_PROD }} \ No newline at end of file diff --git a/.github/workflows/deploy_stage.yml b/.github/workflows/deploy_stage.yml index 7d1e7ff..42a5135 100644 --- a/.github/workflows/deploy_stage.yml +++ b/.github/workflows/deploy_stage.yml @@ -81,6 +81,9 @@ jobs: EBS_JSON_API_KEY_STAGE: ${{ secrets.EBS_JSON_API_KEY_STAGE }} EDGE_COMMERCE_API_BASE: ${{ secrets.EDGE_COMMERCE_API_BASE_STAGE }} EDGE_COMMERCE_API_BASE_STAGE: ${{ secrets.EDGE_COMMERCE_API_BASE_STAGE }} + # recipe-notify — RECIPE_NOTIFY_ENABLED intentionally NOT set on stage, + # so the scheduled digest no-ops here (no duplicate emails). + RECIPE_NOTIFY_TOKEN: ${{ secrets.RECIPE_NOTIFY_TOKEN_STAGE }} uses: adobe/aio-apps-action@3.3.0 with: diff --git a/README.md b/README.md index c9dca97..a5be5e2 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,33 @@ Synchronizes completed commerce orders to Oracle EBS. Runs on a 5-minute cron sc **Supported payment methods**: Credit card (Chase), PayPal, Apple Pay (Chase wallet), Affirm. +### `recipe-notify/recipe-notify` (web action + scheduled) + +Emails a daily digest of **newly published recipes** to a configured list. Runs +on a daily cron and is also available as an HTTP endpoint for status checks and +manual triggers. + +**Production only.** Stage deploys the same manifest, so the scheduled run is +gated by `RECIPE_NOTIFY_ENABLED` — it is set to `"true"` only in the prod deploy +env and the action no-ops immediately otherwise. This prevents duplicate emails +to the shared recipient list (state is per-namespace, but the DA template and its +recipients are shared). + +**Scheduled mode** (daily): Polls the CalcMenu `GetUpdatedRecipes` journal since +a persisted timestamp cursor. The API input is date-granular but responses carry +full-precision `DateUpdated` timestamps and a `Status` (`New`/`Updated`/ +`Deleted`), so the action keeps recipes whose `DateUpdated` is strictly newer +than the cursor (millisecond compare — dedupes the re-fetched boundary day), +filters to `Status="New"`, resolves each recipe's deep link from the published +`query-index.json` (by `Number`), and sends the digest. The cursor advances to +the batch's max `DateUpdated` only on a successful run. The first run cold-starts +(seeds the cursor without emailing) to avoid blasting the back-catalogue. + +**Recipients + copy**: authored in a DA document at +`/config/recipes/digest-template` (same format as `email-template.html`, with a +`{{digest}}` placeholder where the recipe table is injected). No recipient env +var. + ## APIs ### Form Submission @@ -60,6 +87,32 @@ Content-Type: application/json Triggers a sync run starting from the given timestamp. `since` is required. Provide `until` or `duration` (not both) to cap the query window; omit both to scan up to the current time. The cursor advances normally after a successful run. +### Recipe Notify Status + +``` +GET /recipe-notify/recipe-notify +Authorization: Bearer +``` + +Returns JSON: `since`, `lastRun`, `status`, `processedCount`, `lastError`. + +### Recipe Notify Manual Trigger + +``` +POST /recipe-notify/recipe-notify +Authorization: Bearer +Content-Type: application/json + +{ + "since": "", // optional cursor override (backfill) + "dryRun": true // optional — compute new recipes + links, do not send +} +``` + +`dryRun` bypasses the prod gate and never sends or advances the cursor, so stage +can be exercised on demand. It returns the computed new-recipe set with resolved +deep links. + ## Deployment Deployed via GitHub Actions using `aio app deploy` and semantic-release. @@ -112,6 +165,25 @@ Production deployments use semantic-release to version and tag releases automati | `EBS_API_KEY_STAGE` | Stage EBS API key | | `PROXY_TOKEN` | Bearer token for the static-IP proxy | +### recipe-notify package + +All `RECIPE_*` value vars have in-code defaults (see `sync.js`); override only if they change. + +| Variable | Description | Default | +|---|---|---| +| `ORG` / `SITE` | Org/site slug | `aemsites` / `vitamix` | +| `LOG_LEVEL` | Logging level | `info` | +| `EMAIL_TOKEN` | Productbus email service token (reused from forms) | — | +| `AIO_CLIENTID` / `AIO_CLIENTSECRET` / `AIO_SCOPES` | S2S creds to mint the IMS token for reading the DA template (reused from forms) | — | +| `RECIPE_API_URL` | CalcMenu `GetUpdatedRecipes` base URL | `https://vitamix.calcmenuweb.com/ws/service.asmx/GetUpdatedRecipes` | +| `RECIPE_API_ID` | CalcMenu API user id | `API` | +| `RECIPE_API_PSWD` | CalcMenu API password | `Vitamix!` | +| `RECIPE_SITE_BASE` | Deep-link site base | `https://www.vitamix.com` | +| `RECIPE_LINK_LOCALE` | Deep-link locale path | `us/en_us` | +| `RECIPE_DIGEST_TEMPLATE` | DA path of the digest template | `/config/recipes/digest-template` | +| `RECIPE_NOTIFY_TOKEN` | Bearer token for the status/trigger HTTP APIs (HTTP access denied if unset) | — | +| `RECIPE_NOTIFY_ENABLED` | Prod-only gate — scheduled run sends only when `"true"`. Set in prod deploy env only. | unset (no-op) | + ## Setup (first-time per environment) 1. [Create App Builder project](https://experienceleague.adobe.com/en/docs/experience-manager-learn/cloud-service/asset-compute/set-up/app-builder) diff --git a/app.config.yaml b/app.config.yaml index 4eb14d5..1b53ccc 100644 --- a/app.config.yaml +++ b/app.config.yaml @@ -108,3 +108,51 @@ application: ebs-sync-rule: trigger: ebs-sync-schedule action: ebs-sync + + recipe-notify: + license: Apache-2.0 + actions: + recipe-notify: + function: dist/actions/recipe-notify/index.js + runtime: nodejs:22 + web: 'yes' + annotations: + require-adobe-auth: false + final: true + limits: + timeout: 120000 # 2 min — one API call + one email + memory: 256 + concurrency: 1 + inputs: + ORG: aemsites + SITE: vitamix + LOG_LEVEL: $LOG_LEVEL + EMAIL_TOKEN: $EMAIL_TOKEN + # AIO server-to-server creds — mint the IMS token used to read the + # DA digest template (same creds the forms/* actions use) + AIO_CLIENTID: $AIO_CLIENTID + AIO_CLIENTSECRET: $AIO_CLIENTSECRET + AIO_SCOPES: $AIO_SCOPES + # CalcMenu recipe journal API + RECIPE_API_URL: $RECIPE_API_URL + RECIPE_API_ID: $RECIPE_API_ID + RECIPE_API_PSWD: $RECIPE_API_PSWD + # deep links + RECIPE_SITE_BASE: $RECIPE_SITE_BASE + RECIPE_LINK_LOCALE: $RECIPE_LINK_LOCALE + # bearer for manual HTTP trigger/status + RECIPE_NOTIFY_TOKEN: $RECIPE_NOTIFY_TOKEN + # prod-only gate — set "true" ONLY in the prod deploy env; unset in stage + RECIPE_NOTIFY_ENABLED: $RECIPE_NOTIFY_ENABLED + + triggers: + recipe-notify-schedule: + feed: /whisk.system/alarms/alarm + inputs: + cron: '0 12 * * *' # daily 12:00 UTC (recipes land ~04:00 UTC) + timezone: UTC + + rules: + recipe-notify-rule: + trigger: recipe-notify-schedule + action: recipe-notify diff --git a/example.env b/example.env index 5a1bfb8..3fc010a 100644 --- a/example.env +++ b/example.env @@ -66,4 +66,18 @@ SYNC_STATUS_TOKEN= # - journal:orders:read EDGE_COMMERCE_API_ORDERS_TOKEN_STAGE= EDGE_COMMERCE_API_ORDERS_TOKEN_PROD= -EDGE_COMMERCE_API_ORDERS_TOKEN= \ No newline at end of file +EDGE_COMMERCE_API_ORDERS_TOKEN= + +# recipe-notify action — CalcMenu recipe journal API +# All have sensible in-code defaults; override here only if they change. +RECIPE_API_URL=https://vitamix.calcmenuweb.com/ws/service.asmx/GetUpdatedRecipes +RECIPE_API_ID=API +RECIPE_API_PSWD=Vitamix! +# recipe-notify action — deep links +RECIPE_SITE_BASE=https://www.vitamix.com +RECIPE_LINK_LOCALE=us/en_us +# recipe-notify action — HTTP trigger/status auth token +# generate with: node -e "console.log(require('crypto').randomUUID())" +RECIPE_NOTIFY_TOKEN= +# recipe-notify action — prod-only gate. Set "true" in PROD ONLY; leave blank in stage. +RECIPE_NOTIFY_ENABLED= \ No newline at end of file diff --git a/src/actions/recipe-notify/detect.js b/src/actions/recipe-notify/detect.js new file mode 100644 index 0000000..2fc8248 --- /dev/null +++ b/src/actions/recipe-notify/detect.js @@ -0,0 +1,48 @@ +/** + * Change detection. + * + * `Status` is sticky (a recipe reads "New" for as long as it stays in that + * lifecycle state, regardless of the query cursor), so it cannot mean "new since + * last run" on its own. What makes a recipe *newly detected* is its + * `dateUpdated` crossing the persisted cursor. So we: + * 1. keep only recipes whose full-precision `dateUpdated` is strictly greater + * than the cursor (dedupes the date-granular re-fetch of the boundary day), + * 2. classify the survivors — for v1 we notify `Status === "New"` only, + * 3. compute the batch's max `dateUpdated` (across ALL recipes, including the + * ignored Updated/Deleted rows) so the cursor advances past everything we + * inspected and nothing is re-examined next run. + */ + +/** + * @param {import('./recipes.js').Recipe[]} recipes + * @param {string | null} since - cursor timestamp, or null on cold start + * @returns {{ + * changed: import('./recipes.js').Recipe[], + * newRecipes: import('./recipes.js').Recipe[], + * maxUpdated: string | null, + * }} + */ +export function detectNewRecipes(recipes, since) { + const sinceMs = since ? Date.parse(since) : NaN; + + const changed = recipes.filter((r) => { + const t = Date.parse(r.dateUpdated); + if (Number.isNaN(t)) return false; + return Number.isNaN(sinceMs) ? true : t > sinceMs; + }); + + const newRecipes = changed.filter((r) => r.status.toLowerCase() === 'new'); + + // Max dateUpdated across the whole batch, preserving the original string form. + let maxUpdated = since; + let maxMs = sinceMs; + for (const r of recipes) { + const t = Date.parse(r.dateUpdated); + if (!Number.isNaN(t) && (Number.isNaN(maxMs) || t > maxMs)) { + maxMs = t; + maxUpdated = r.dateUpdated; + } + } + + return { changed, newRecipes, maxUpdated }; +} diff --git a/src/actions/recipe-notify/index.js b/src/actions/recipe-notify/index.js new file mode 100644 index 0000000..92bf352 --- /dev/null +++ b/src/actions/recipe-notify/index.js @@ -0,0 +1,115 @@ +/** + * Adobe I/O Runtime action — recipe-notify. + * + * Sends a daily digest of newly published recipes. Runs in PRODUCTION ONLY, + * gated by RECIPE_NOTIFY_ENABLED === "true" (see sync.js). + * + * Three invocation modes: + * + * Scheduled (alarm rule, daily) + * params.__ow_method is absent. Runs the full job via run(). + * + * HTTP GET (web action) — status + * Requires: Authorization: Bearer {RECIPE_NOTIFY_TOKEN} + * Returns: JSON metadata about the last/current run. + * + * HTTP POST (web action) — manual trigger + * Requires: Authorization: Bearer {RECIPE_NOTIFY_TOKEN} + * Body: { "since"?: "", "dryRun"?: true } + * `dryRun` computes the new-recipe set (with resolved links) without sending + * and without advancing the cursor — and bypasses the prod gate, so stage + * can be exercised on demand. + */ + +import { run } from './sync.js'; +import { loadState } from './state.js'; + +export async function main(params) { + // Web-action invocations carry __ow_method; scheduled invocations do not. + if (params.__ow_method) { + const method = params.__ow_method.toUpperCase(); + if (method === 'GET') return handleStatusRequest(params); + if (method === 'POST') return handleTriggerRequest(params); + return jsonResponse(405, { error: 'Method Not Allowed' }); + } + + return run(params); +} + +/** + * Validate the shared status/trigger Bearer token. + * If no token is configured, all HTTP access is denied. + * @returns {object | null} JSON error response if invalid, null if OK + */ +function requireAuth(params) { + const authHeader = (params.__ow_headers || {}).authorization || ''; + const provided = authHeader.replace(/^Bearer\s+/i, '').trim(); + + if (!params.RECIPE_NOTIFY_TOKEN || !provided || provided !== params.RECIPE_NOTIFY_TOKEN) { + return jsonResponse(401, { error: 'Unauthorized' }); + } + return null; +} + +/** GET — return current state metadata. */ +async function handleStatusRequest(params) { + const authErr = requireAuth(params); + if (authErr) return authErr; + + let state; + try { + state = await loadState(); + } catch (err) { + return jsonResponse(500, { error: 'Failed to read state', detail: err.message }); + } + + return jsonResponse(200, { + since: state.since, + lastRun: state.lastRun, + status: state.status, + processedCount: state.processedCount, + lastError: state.lastError, + }); +} + +/** + * POST — manually trigger a run. + * Body: { "since"?: "", "dryRun"?: true } + */ +async function handleTriggerRequest(params) { + const authErr = requireAuth(params); + if (authErr) return authErr; + + const body = parseBody(params); + + if (body.since !== undefined && body.since !== null && isNaN(Date.parse(body.since))) { + return jsonResponse(400, { error: 'Invalid "since" ISO 8601 timestamp' }); + } + + const result = await run(params, { + dryRun: body.dryRun === true, + sinceOverride: body.since ?? undefined, + }); + return jsonResponse(result.body?.error ? 500 : 200, result.body); +} + +/** Extract body fields from params (auto-parsed JSON) or __ow_body (base64). */ +function parseBody(params) { + if (params.since !== undefined || params.dryRun !== undefined) { + return { since: params.since, dryRun: params.dryRun }; + } + if (!params.__ow_body) return {}; + try { + return JSON.parse(Buffer.from(params.__ow_body, 'base64').toString('utf-8')); + } catch { + return {}; + } +} + +function jsonResponse(statusCode, body) { + return { + statusCode, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }; +} diff --git a/src/actions/recipe-notify/links.js b/src/actions/recipe-notify/links.js new file mode 100644 index 0000000..f17176a --- /dev/null +++ b/src/actions/recipe-notify/links.js @@ -0,0 +1,80 @@ +/** + * Deep-link resolution. + * + * A recipe's public page URL is `{base}/{locale}/recipes/{slug}-{number}` where + * the trailing segment is the recipe `Number` lowercased. Rather than + * reconstruct the slug (fragile with trademarks/accents/punctuation), we resolve + * the authoritative path from the published `query-index.json` by matching the + * `Number` suffix. New recipes are typically already present in the index at + * notification time; if a recipe isn't found, its link is left null. + */ + +/** + * The index also carries each recipe's `image` (the same asset used for the page + * `og:image`), so we resolve a small thumbnail URL from it here — no extra + * requests needed. + * + * @param {Context} ctx + * @param {import('./recipes.js').Recipe[]} recipes + * @returns {Promise<(import('./recipes.js').Recipe & { url: string | null, image: string | null })[]>} + */ +export async function resolveLinks(ctx, recipes) { + const { env, log } = ctx; + const base = env.RECIPE_SITE_BASE; + const locale = env.RECIPE_LINK_LOCALE; + + /** @type {Map} number(lowercased) → entry */ + const byNumber = new Map(); + try { + const resp = await fetch(`${base}/${locale}/recipes/query-index.json`); + if (resp.ok) { + const data = await resp.json(); + for (const entry of data?.data ?? []) { + const path = entry?.path; + if (typeof path === 'string') { + const dash = path.lastIndexOf('-'); + if (dash !== -1) { + byNumber.set(path.slice(dash + 1).toLowerCase(), { + path, + image: typeof entry.image === 'string' && entry.image ? entry.image : null, + }); + } + } + } + } else { + log.warn(`[recipe-notify] query-index fetch failed: ${resp.status} — links will be omitted`); + } + } catch (err) { + log.warn(`[recipe-notify] query-index fetch error: ${err.message} — links will be omitted`); + } + + return recipes.map((r) => { + const entry = byNumber.get(r.number.toLowerCase()); + return { + ...r, + url: entry ? `${base}${entry.path}` : null, + image: entry ? thumbnailUrl(entry.image) : null, + }; + }); +} + +/** + * Turn an index image URL into a small, email-friendly thumbnail: constrain the + * width and force JPEG (`format=pjpg`) since AVIF isn't reliably rendered by + * email clients. Returns null if there's no image. + * @param {string | null} image + * @param {number} [width] + * @returns {string | null} + */ +export function thumbnailUrl(image, width = 240) { + if (!image) return null; + try { + const url = new URL(image); + url.searchParams.set('width', String(width)); + url.searchParams.set('format', 'pjpg'); + url.searchParams.set('optimize', 'medium'); + return url.toString(); + } catch { + return image; + } +} diff --git a/src/actions/recipe-notify/notify.js b/src/actions/recipe-notify/notify.js new file mode 100644 index 0000000..753b9f1 --- /dev/null +++ b/src/actions/recipe-notify/notify.js @@ -0,0 +1,125 @@ +/** + * Digest rendering and delivery. + * + * Renders the new recipes as an HTML table, injects it into the DA digest + * template's `{{digest}}` placeholder, and sends via the shared email service. + * Recipients and subject come from the template (`/config/recipes/digest-template`). + */ + +import { resolveDigestTemplate, sendEmail } from '../../emails.js'; +import { resolveLinks } from './links.js'; + +/** Raised when the digest template document is missing, so the run can skip the send without advancing the cursor. */ +export class TemplateMissingError extends Error { + constructor(path) { + super(`digest template not found at ${path}`); + this.name = 'TemplateMissingError'; + this.templatePath = path; + } +} + +/** + * Escape a string for safe inclusion in HTML. + * @param {string} str + * @returns {string} + */ +function escapeHtml(str) { + return String(str ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/** + * Format an API date (`2026-07-17T04:10:00`) as `YYYY-MM-DD`, or '' if + * unparseable. Uses the literal date portion to avoid timezone drift (the API + * timestamps carry no zone, so a UTC conversion could shift the displayed day). + * @param {string | null} value + * @returns {string} + */ +function formatDate(value) { + if (!value) return ''; + const m = String(value).match(/^(\d{4}-\d{2}-\d{2})/); + if (m) return m[1]; + const t = Date.parse(value); + return Number.isNaN(t) ? '' : new Date(t).toISOString().slice(0, 10); +} + +// Inline styles — email clients don't reliably support