Skip to content

None of these have been run against live Gmail, Stripe or a PDF service yet.

Exercise 1

Add a Schedule Trigger Weekdays 9 AM and a fifth Route rule → Code Find Follow-ups:

const db = $getWorkflowStaticData('global');
const now = new Date();
const days = (iso) => (now - new Date(iso)) / 864e5;
const today = now.toISOString().slice(0, 10);
const inTwo = new Date(now.getTime() + 2 * 864e5).toISOString().slice(0, 10);
const out = [];
for (const p of Object.values(db.proposals ?? {})) {
  if (p.status !== 'sent') continue;
  if (!p.first_viewed_at && days(p.sent_at) >= 3 && !p.nudged_unviewed) {
    p.nudged_unviewed = now.toISOString();
    out.push({ json: { to: p.lead.email, subject: `Did our proposal reach you?`, body: `Hi ${p.lead.client.split(' ')[0]},\n\nJust checking the proposal reached you: ${p.link}\n\nHappy to walk through it on a quick call.` } });
  } else if (p.valid_until <= inTwo && p.valid_until >= today && !p.nudged_expiring) {
    p.nudged_expiring = now.toISOString();
    out.push({ json: { to: p.lead.email, subject: `Your proposal expires ${p.valid_until}`, body: `Hi ${p.lead.client.split(' ')[0]},\n\nYour proposal is valid until ${p.valid_until}: ${p.link}\n\nWant me to hold the start date?` } });
  }
}
return out;

→ Gmail Send ({{ $json.to }}, {{ $json.subject }}, {{ $json.body }}, On Error → Continue). Marking before sending means a failed send isn't retried; mark in a Code node after Gmail (as module 21 does) if you want retries.

Exercise 2

In the accept lane after Accepted?:

  1. HTTP Request POST https://api.stripe.com/v1/payment_links, Predefined Credential Type → Stripe API, body Form URL-encoded:

    • line_items[0][price_data][currency] = usd
    • line_items[0][price_data][product_data][name] = Deposit {{ $('Accept Proposal').first().json.id }}
    • line_items[0][price_data][unit_amount] = {{ $('Accept Proposal').first().json.deposit_cents }}
    • line_items[0][quantity] = 1

    (Add deposit_cents: p.quote.deposit * 100 to accept.js's output.) Stripe's payment links API may require a Price object instead of price_data; if so, create a Price first with POST /v1/prices.

  2. In Respond Thanks, add <p><a href="{{ $json.url }}" style="...">Pay deposit</a></p> when $json.url exists, and put the link in the owner email.

Exercise 3

Store the final HTML at accept time, then before Notify Owner:

  1. HTTP Request to a self-hosted Gotenberg (POST http://gotenberg:3000/forms/chromium/convert/html, multipart field files = an index.html built from {{ $('Accept Proposal').first().json.html }}), Response Format: File.
  2. In Notify Owner → Options → Attachments → binary property data.

Add Gotenberg to playground/docker-compose.yml (image: gotenberg/gotenberg:8). Return the page HTML from accept.js (p.html with the accepted banner) so the PDF matches what the client saw.

Stretch

In accept.js, push { service: i.service, text: p.text } for each item into db.wins (keep the last 20). In Build Quote, attach examples: db.wins.filter(w => services.includes(w.service)).slice(-2), and add to the prompt: Examples of proposals that were accepted (match the style, not the content): {{ JSON.stringify($json.examples) }}.