diff --git a/package.json b/package.json index 122d1d9..1d308c3 100644 --- a/package.json +++ b/package.json @@ -25,5 +25,8 @@ }, "workspaces": [ "widget" - ] + ], + "volta": { + "node": "18.17.0" + } } diff --git a/src/http/get-benefits/index.js b/src/http/get-benefits/index.js index 4a2c225..92822b5 100644 --- a/src/http/get-benefits/index.js +++ b/src/http/get-benefits/index.js @@ -2,6 +2,9 @@ const arc = require("@architect/functions"); const { assembleLinks } = require("@architect/shared/links"); const { applyRules } = require("@architect/shared/rules"); const { getDefinitions } = require("@architect/shared/s3"); +const { generateHtml } = require("@architect/shared/templates"); +const { matchHostDef } = require("@architect/shared/hosts"); +const { getThrottles } = require("@architect/shared/throttles"); // Definitions will be loaded from benefits-recs-defs.json in S3. // We keep it outside the handler to cache it between Lambda runs. @@ -12,15 +15,37 @@ let definitions = {}; exports.handler = arc.http.async(async (req) => { // If definitions is empty, fetch it from S3. if (Object.keys(definitions).length === 0) { + console.log("Definitions cache is cold. Fetching definitions from S3."); definitions = await getDefinitions(); } - // Grab data from URL query parameters. + const { + targets: targetDefs = [], + hosts: hostDefs = [], + throttles: throttleDefs = [], + } = definitions; + + // Grab data from headers and URL query parameters. const host = decodeURIComponent(req.query.host || ""); const language = req.query.language || "en"; + const acceptHeader = req.headers?.accept; + + // Process metadata. + const hostDef = matchHostDef(host, hostDefs); + const throttles = await getThrottles(throttleDefs); + + // Create target links. + const allLinks = assembleLinks(targetDefs, language, hostDef); + const links = await applyRules(throttles, allLinks, hostDef); + + // If we don't have any links, exit now with no content. + if (links.length === 0) { + return { + cors: true, + statusCode: 204, + }; + } - const allLinks = assembleLinks(definitions, language, host); - const links = await applyRules(definitions, allLinks, host); const data = { header: "Apply for more benefits!", tagline: "You might be able to get:", @@ -29,6 +54,15 @@ exports.handler = arc.http.async(async (req) => { links, }; + // If the client wants HTML, send it. + if (acceptHeader === "text/html") { + return { + cors: true, + html: generateHtml(data, hostDef), + }; + } + + // Otherwise, default to JSON. return { cors: true, json: JSON.stringify(data), diff --git a/src/shared/hosts.js b/src/shared/hosts.js new file mode 100644 index 0000000..90624cc --- /dev/null +++ b/src/shared/hosts.js @@ -0,0 +1,26 @@ +const url = require("./url"); + +/** + * Find the host definition for a given host url. + * We want to match up the origin of the request (host) with our own metadata (hostDefs). + * @param {string} host + * The host page, as a URL (string), from which the widget sent this request. + * @param {import('./s3.js').Host[]} hostDefs + * A list of host objects from the Airtable-derived definitions. + * @returns {import('./s3.js').Host|undefined} + * A matching host definition, or undefined if none are found. + */ +exports.matchHostDef = (host, hostDefs) => { + const pHostUrl = url.parse(host); + + if (pHostUrl && hostDefs) { + return hostDefs.find((hostDef) => + hostDef.urls.some((hostDefUrl) => { + const pHostDefUrl = url.parse(hostDefUrl); + return pHostDefUrl.hostname === pHostUrl.hostname; + }) + ); + } else { + return undefined; + } +}; diff --git a/src/shared/links.js b/src/shared/links.js index 6082d3e..8c464f2 100644 --- a/src/shared/links.js +++ b/src/shared/links.js @@ -6,31 +6,6 @@ const AnalyticEngines = { PIWIK: "piwik", }; -/** - * Find the host definition for a given host url. - * We want to match up the origin of the request (host) with our own metadata (hostDefs). - * @param {string} host - * The host page, as a URL (string), from which the widget sent this request. - * @param {import('./s3.js').Host[]} hostDefs - * A list of host objects from the Airtable-derived definitions. - * @returns {import('./s3.js').Host|undefined} - * A matching host definition, or undefined if none are found. - */ -const findHostDef = (host, hostDefs) => { - const pHostUrl = url.parse(host); - - if (pHostUrl) { - return hostDefs.find((hostDef) => - hostDef.urls.some((hostDefUrl) => { - const pHostDefUrl = url.parse(hostDefUrl); - return pHostDefUrl.hostname === pHostUrl.hostname; - }) - ); - } else { - return undefined; - } -}; - /** * Add query string parameters for the target site's analytics engine. * @param {string} linkUrl @@ -76,20 +51,17 @@ const addAnalytics = (linkUrl, analytics, hostDef) => { */ /** - * Construct link objects for the given language and host. - * @param {import('./s3.js').Definitions} definitions - * A parsed object representing the Airtable-derived `benefits-recs-defs.json` file. + * From the list of target definitions, construct link objects for the given language and host. + * @param {import('./s3.js').Target[]} targets + * Target definitions from the Airtable-derived `benefits-recs-defs.json` file. * @param {string} language * The language for this request, as an ISO 639-1 code. - * @param {string} host + * @param {import('./s3.js').Host} hostDef * The host page, as a URL (string), from which the widget sent this request. * @returns {TargetLink[]} * A processed list of target links in the requested language. */ -exports.assembleLinks = (definitions, language, host) => { - const { targets, hosts: hostDefs } = definitions; - const hostDef = findHostDef(host, hostDefs); - +exports.assembleLinks = (targets, language, hostDef) => { // Unless it's Chinese, strip the language code down to two characters. // We need to preserve the Chinese code to display Traditional vs. Simplified. const langKey = language.startsWith("zh") ? language : language.slice(0, 2); diff --git a/src/shared/rules.js b/src/shared/rules.js index bf33a55..c15afc5 100644 --- a/src/shared/rules.js +++ b/src/shared/rules.js @@ -1,5 +1,4 @@ const url = require("./url"); -const { getThrottles } = require("./throttles"); /** * rules.js @@ -17,8 +16,8 @@ const { getThrottles } = require("./throttles"); * A processed list of all target links in the user's requested language. * @param {object} params * A collection of parameters from the request to help inform the rule. - * @param {string} [params.host] - * The host page, as a URL (string), from which the widget sent this request. + * @param {import('./s3.js').Host} [params.hostDef] + * The host partner from which the widget sent this request. * @param {import('./s3.js').Throttle[]} [params.throttles] * A processed list of target link throttles. * @returns {import('./links.js').TargetLink[]} @@ -42,8 +41,8 @@ const removeThrottledLinks = (links, { throttles }) => * Remove links that point back to the same host site as the widget. * @type {Rule} */ -const removeLinkBacks = (links, { host }) => - links.filter((link) => !url.matchHosts(host, link.url)); +const removeLinkBacks = (links, { hostDef }) => + links.filter((link) => !url.findHostMatch(hostDef?.urls || [], link.url)); /** * Randomize the order of the links. @@ -74,20 +73,18 @@ const rules = [ /** * Apply the rules to the given list of links. - * @param {import('./s3.js').Definitions} definitions - * A parsed object representing the Airtable-derived `benefits-recs-defs.json` file. + * @param {import('./s3.js').Throttle[]} throttles + * Active throttles against target links. * @param {import('./links.js').TargetLink[]} allLinks * A processed list of all target links in the user's requested language. - * @param {string} host - * The host page, as a URL (string), from which the widget sent this request. + * @param {import('./s3.js').Host} hostDef + * The identified host partner from which the widget sent this request. * @returns {Promise} * A targetted list of target links. */ -const applyRules = async (definitions, allLinks, host) => { - const throttles = await getThrottles(definitions); - +const applyRules = async (throttles, allLinks, hostDef) => { const params = { - host, + hostDef, throttles, }; diff --git a/widget/src/templates.js b/src/shared/templates.js similarity index 78% rename from widget/src/templates.js rename to src/shared/templates.js index 462d05c..0d9ad9c 100644 --- a/widget/src/templates.js +++ b/src/shared/templates.js @@ -1,4 +1,4 @@ -export const css = /* css */ ` +const defaultCss = /* css */ ` :host { --benefits-recs-background-color: #E1F1EE; --benefits-recs-highlight-color: #006C58; @@ -119,16 +119,7 @@ ul.link-list li a:hover { } `; -export const rootHtml = /* html */ ` -
-

Claim more benefits

-

You could qualify to get:

- -
-`; - -export const openIcon = /* html */ ` +const openIcon = /* html */ ` @@ -141,7 +132,7 @@ export const openIcon = /* html */ ` `; -export const linkHtml = (link) => /* html */ ` +const defaultLinkHtml = (link) => /* html */ `
  • @@ -155,3 +146,46 @@ export const linkHtml = (link) => /* html */ `
  • `; + +const defaultHtml = (data) => { + const linkList = data.links.map((link) => defaultLinkHtml(link)).join("\n"); + + return /* html */ ` +
    +

    ${data.header}

    +

    ${data.tagline}

    + +
    + `; +}; + +const defaultTemplate = (data) => /* html */ ` + + ${defaultHtml(data)} +`; + +const eddUiTemplate = (data) => /* html */ ` + + ${defaultHtml(data)} +`; + +exports.generateHtml = (data, hostDef) => { + if (hostDef?.id === "edd_ui_recert") { + return eddUiTemplate(data); + } + + return defaultTemplate(data); +}; diff --git a/src/shared/throttles.js b/src/shared/throttles.js index 25bee68..47232cb 100644 --- a/src/shared/throttles.js +++ b/src/shared/throttles.js @@ -5,14 +5,12 @@ const arc = require("@architect/functions"); * We check DynamoDB to see if each throttle is still within daily clickthrough limits. * We also look at other parameters, like start and end dates. * The goal is to see which throttles need to be activated for this request. - * @param {import('./s3.js').Definitions} definitions - * A parsed object representing the Airtable-derived `benefits-recs-defs.json` file. + * @param {import('./s3.js').Throttle[]} throttleDefs + * Throttle definitions from the Airtable-derived `benefits-recs-defs.json` file. * @returns {Promise} * A hydrated list of target link throttles. */ -exports.getThrottles = async (definitions) => { - const { throttles: throttleDefs } = definitions; - +exports.getThrottles = async (throttleDefs) => { try { const throttles = [...throttleDefs]; @@ -47,7 +45,7 @@ exports.getThrottles = async (definitions) => { } // If the throttle is still open, we need to check DynamoDB. - if ((throttle.exceeded = false)) { + if (limit && throttle.exceeded === false) { const promise = dynamo.throttleclicks .get({ name, day }) .then((response) => { diff --git a/src/shared/url.js b/src/shared/url.js index d7152ec..594cb7a 100644 --- a/src/shared/url.js +++ b/src/shared/url.js @@ -111,7 +111,7 @@ const matchPaths = (url1, url2) => { /** * Checks if a URL is included in a list of other URLs. - * @param {Array} urls An array of URL strings. + * @param {string[]} urls An array of URL strings. * @param {string} queryUrl A string representation of the URL to check. * @returns {boolean} */ @@ -122,8 +122,24 @@ const findMatch = (urls, queryUrl) => { return pQueryUrl ? pUrls.some((pUrl) => compare(pUrl, pQueryUrl)) : false; }; +/** + * Checks if a URL's host is included in a list of other URLs. + * @param {string[]} urls An array of URL strings. + * @param {string} queryUrl A string representation of the URL to check. + * @returns {boolean} + */ +const findHostMatch = (urls, queryUrl) => { + const pQueryUrl = parse(queryUrl); + const pUrls = urls.map((u) => parse(u)).filter((pU) => pU !== undefined); + + return pQueryUrl + ? pUrls.some((pUrl) => compareHosts(pUrl, pQueryUrl)) + : false; +}; + exports.parse = parse; exports.match = match; exports.matchHosts = matchHosts; exports.matchPaths = matchPaths; exports.findMatch = findMatch; +exports.findHostMatch = findHostMatch; diff --git a/widget/preview/generate.js b/widget/preview/generate.js index 4394ab3..665700d 100644 --- a/widget/preview/generate.js +++ b/widget/preview/generate.js @@ -2,7 +2,7 @@ const fs = require("fs/promises"); const generate = (props) => { const pr = props.prNumber - ? `(Pull Request #${props.prNumber})` + ? `(Pull Request #${props.prNumber})` : ""; return /* html */ ` diff --git a/widget/src/cagov-benefits-recs.js b/widget/src/cagov-benefits-recs.js index c5e555c..eec4ec8 100644 --- a/widget/src/cagov-benefits-recs.js +++ b/widget/src/cagov-benefits-recs.js @@ -1,30 +1,19 @@ -import { css, rootHtml, linkHtml } from "./templates.js"; - export class CaGovBenefitsRecs extends window.HTMLElement { constructor() { super(); } connectedCallback() { - this.endpoint = ( - this.hasAttribute("endpoint") - ? this.getAttribute("endpoint") - : "https://br.api.innovation.ca.gov" - ).replace(/\/$/, ""); - - this.language = this.hasAttribute("language") - ? this.getAttribute("language") - : document.querySelector("html").getAttribute("lang"); + const defaultEndpoint = "https://br.api.innovation.ca.gov"; + this.endpoint = this.getAttribute("endpoint") || defaultEndpoint; - this.income = this.hasAttribute("income") - ? this.getAttribute("income") - : ""; + const lang = document.querySelector("html").getAttribute("lang"); + this.language = this.getAttribute("language") || lang; - this.host = this.hasAttribute("host") - ? this.getAttribute("host") - : window.location.href; + this.income = this.getAttribute("income"); + this.host = this.getAttribute("host") || window.location.href; - const benefitsUrl = new URL(`${this.endpoint}/benefits`); + const benefitsUrl = new URL("/benefits", this.endpoint); // We'll append the query parameters to the URL of our API call. const queryKeys = ["host", "language"]; @@ -35,51 +24,30 @@ export class CaGovBenefitsRecs extends window.HTMLElement { // Retrieve set of benefits links from API. fetch(benefitsUrl.href, { headers: { - "Content-Type": "application/json", + Accept: "text/html", }, }) - .catch((error) => { - throw new Error( - `Benefits Recommendation API unavailable. Hiding widget.`, - { cause: error } - ); - }) - .then((response) => response.json()) - .then((json) => JSON.parse(json)) - .then((data) => { - // Only render the widget if we actually get valid links. - if (data.links && data.links.length > 1) { - const template = document.createElement("template"); - template.innerHTML = rootHtml; - - const style = document.createElement("style"); - style.textContent = css; - - template.content.prepend(style); + .then(async (response) => { + const html = await response.text(); + // Only render the widget if we actually get valid links. + if (html && response.ok) { this.attachShadow({ mode: "open" }); - this.shadowRoot.append(template.content.cloneNode(true)); - - this.shadowRoot.querySelector("h2").innerHTML = data.header; - this.shadowRoot.querySelector("p.tagline").innerHTML = data.tagline; - let listContainer = this.shadowRoot.querySelector("ul.link-list"); - data.links.forEach((link) => { - listContainer.innerHTML += linkHtml(link); - }); + this.shadowRoot.innerHTML = html; + const section = this.shadowRoot.querySelector("section"); - this.experimentName = data.experimentName; - this.experimentVariation = data.experimentVariation; + this.experimentName = section.dataset.experimentName; + this.experimentVariation = section.dataset.experimentVariation; this.recordEvent("render"); this.applyListeners(); - } else { - console.log( - "No links received by Benefits Recommendation API. Hiding widget." - ); } }) .catch((error) => { - console.log(error); + throw new Error( + `Benefits Recommendation API unavailable. Hiding widget.`, + { cause: error } + ); }); } @@ -94,8 +62,9 @@ export class CaGovBenefitsRecs extends window.HTMLElement { }; const data = Object.assign({ event }, defaults, details); + const eventUrl = new URL("/event", this.endpoint); - navigator.sendBeacon(`${this.endpoint}/event`, JSON.stringify(data)); + navigator.sendBeacon(eventUrl.href, JSON.stringify(data)); } applyListeners() {