Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/deploy_prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
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 }}
3 changes: 3 additions & 0 deletions .github/workflows/deploy_stage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/[email protected]
with:
Expand Down
72 changes: 72 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <RECIPE_NOTIFY_TOKEN>
```

Returns JSON: `since`, `lastRun`, `status`, `processedCount`, `lastError`.

### Recipe Notify Manual Trigger

```
POST /recipe-notify/recipe-notify
Authorization: Bearer <RECIPE_NOTIFY_TOKEN>
Content-Type: application/json

{
"since": "<ISO 8601>", // 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.
Expand Down Expand Up @@ -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)
Expand Down
48 changes: 48 additions & 0 deletions app.config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
16 changes: 15 additions & 1 deletion example.env
Original file line number Diff line number Diff line change
Expand Up @@ -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=
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=
48 changes: 48 additions & 0 deletions src/actions/recipe-notify/detect.js
Original file line number Diff line number Diff line change
@@ -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 };
}
115 changes: 115 additions & 0 deletions src/actions/recipe-notify/index.js
Original file line number Diff line number Diff line change
@@ -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"?: "<ISO 8601>", "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"?: "<ISO 8601>", "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),
};
}
Loading
Loading