Appearance
Buylist Offer Email 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 merchant clicks "Send Offer to Customer", email the customer their offer with links that reach a working accept/decline page.
Architecture: A fresh claim token is minted at send-offer time (the one from order creation is unreachable by then), stored as a hash with a 30-day expiry. A pure builder turns the order into {subject, html, text}; a single provider adapter posts it to Resend over HTTP. The send is awaited inline with a 5s timeout and can never fail the request — the order still becomes offer_sent, and the merchant gets a copyable link either way.
Tech Stack: Node 24 CommonJS server, Express 5, Mongoose 8, axios (already a dependency — no new npm package), Vitest with ESM test files, React 18 + retroui on the client.
Spec: docs/superpowers/specs/2026-08-25-buylist-offer-email-design.md
Global Constraints
- No new npm dependencies. The Resend REST API is one
axios.post. Do not install theresendSDK. vi.mockof a server module is silently inert —vitest.config.jsexternalizes server source to nativerequire(). Use the_setDeps()/_resetDeps()seam. Every service in this plan exposes one.- Server files are CommonJS (
require); test files are ESM (import) and co-located asx.test.jsnext tox.js. - Every new persisted field is declared field-by-field in the Mongoose schema and ships with a round-trip test (§5.2). Strict mode drops undeclared sub-document fields on write with no error.
- Secrets go in three places in the same sitting (§5.7):
.env.example, the--set-env-varsblock in.github/workflows/deploy-api.yml, and the matching GitHub Actions secret. dotenv-saferuns withallowEmptyValues: true(server/index.js:9) — a key added to.env.examplemust exist in every local.env, but may be empty.- §5.1 game parity is N/A for this whole plan — nothing here is per-game. State that explicitly in the PR description.
- Never make accept/decline a GET link in an email. Gmail and Outlook prefetch links; a GET that mutates would auto-accept offers. Every email link lands on the portal page, which POSTs on a real click.
- Commit messages: one imperative sentence naming the merchant-visible outcome, sentence case, no trailing period.
- Verify with
npm testand read the per-file coverage table yourself — the Vitest 4 threshold gate is silently inoperative and exits 0 regardless.
Task 0: Provision the sending domain (mostly automatable)
Nothing leaves the building until send.lgsforge.com is verified. Only one step here needs a human — creating the Resend account and API key, which is a browser login. Everything after that is API + gcloud.
Preconditions established by inspection, not assumption:
lgsforge.comis a Google Cloud DNS zone namedlgsforge-comandgcloudis authenticated on this machine, so records can be written from the CLI.- The root domain already sends mail through Zoho —
MX → mx.zoho.com,TXT v=spf1 include:zohomail.com ~all, and a DKIM key atlgsforge._domainkey.lgsforge.com. This is why we send from the subdomain: nothing here touches those records, sosupport@lgsforge.comcannot break. - There is no DMARC record on the zone today.
Files: none — infrastructure only.
- [ ] Step 1 (human, cannot be delegated): create the Resend account and API key
Sign up at resend.com, create an API key with Sending access, and export it locally:
bash
export RESEND_API_KEY=re_...A subagent cannot do this: it requires a browser login and an account that doesn't exist yet. Every later step in this task can be run by an agent once the key exists.
- [ ] Step 2: Register the sending domain and capture the records Resend wants
bash
curl -s -X POST https://api.resend.com/domains \
-H "Authorization: Bearer $RESEND_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"send.lgsforge.com","region":"us-east-1"}' | tee resend-domain.jsonThe response carries id and a records array, each entry with record, name, type, value, and priority for MX. Use these values verbatim — do not hand-write DKIM or SPF strings (§5.4: a wrong literal here doesn't error, it just never verifies). Note the returned name values are relative and already fully qualified for the registered domain (e.g. send.send.lgsforge.com for the return-path MX); that nesting is expected.
- [ ] Step 3: Write those records into Cloud DNS
bash
gcloud dns record-sets transaction start --zone=lgsforge-com
# One --add per entry in the records array, e.g.:
# MX: gcloud dns record-sets transaction add "10 feedback-smtp.us-east-1.amazonses.com." \
# --name="send.send.lgsforge.com." --ttl=300 --type=MX --zone=lgsforge-com
# TXT: gcloud dns record-sets transaction add "\"v=spf1 include:amazonses.com ~all\"" \
# --name="send.send.lgsforge.com." --ttl=300 --type=TXT --zone=lgsforge-com
# DKIM TXT at resend._domainkey.send.lgsforge.com. — value from the API response
gcloud dns record-sets transaction execute --zone=lgsforge-comIf the transaction fails partway: gcloud dns record-sets transaction abort --zone=lgsforge-com and start again. Names must be fully qualified with a trailing dot; TXT values must be quoted.
- [ ] Step 4: Confirm the records actually resolve
bash
gcloud dns record-sets list --zone=lgsforge-com --name="send.send.lgsforge.com."
nslookup -type=TXT resend._domainkey.send.lgsforge.comExpected: the DKIM TXT resolves and matches the API response value.
- [ ] Step 5: Ask Resend to verify, then poll
bash
DOMAIN_ID=$(node -p "require('./resend-domain.json').id")
curl -s -X POST "https://api.resend.com/domains/$DOMAIN_ID/verify" -H "Authorization: Bearer $RESEND_API_KEY"
curl -s "https://api.resend.com/domains/$DOMAIN_ID" -H "Authorization: Bearer $RESEND_API_KEY"Verification is asynchronous — the domain reads pending while it runs. Re-poll the GET until the status settles. Do not proceed to Task 9's end-to-end test until it reports verified.
- [ ] Step 6: Add a DMARC record —
p=nonefirst
There is no DMARC today, and the root's Zoho mail is live. A restrictive policy added blind can start bouncing support@lgsforge.com, so publish monitoring-only and leave it there until reports look clean:
bash
gcloud dns record-sets create _dmarc.lgsforge.com. --zone=lgsforge-com --type=TXT --ttl=300 \
--rrdatas='"v=DMARC1; p=none; rua=mailto:dmarc@lgsforge.com"'Tightening to p=quarantine/p=reject is a separate, later decision — and must be checked against Zoho alignment first, not just Resend's.
- [ ] Step 7: Delete the scratch file
bash
rm resend-domain.jsonIt contains the domain id and record values; nothing secret, but it must not be committed.
- [ ] Step 8: Record the outcome
No commit — this task changes infrastructure, not the repo. State in the PR: the domain is verified, and RESEND_API_KEY / BUYLIST_EMAIL_FROM=buylist@send.lgsforge.com exist in both the Cloud Run env and the GitHub Actions secrets (§5.7).
Task 1: Persist the shop's customer-facing contact email
The offer email needs a Reply-To that reaches the merchant, not us. We don't store one. Store.branding is already populated from Shopify at install and refreshed when stale, so it's the right home.
Files:
- Modify:
server/services/shopifyAPI.js:762-779(getShopBranding) - Modify:
server/models/Store.js:416-423(brandingsub-document) - Test:
server/models/Store.test.js(exists),server/services/shopifyAPI.test.js(exists)
Interfaces:
- Consumes: nothing.
- Produces:
Store.branding.contactEmail—String|null. Task 7 reads it as theReply-To.
Field-name provenance (§5.4): Shop.contactEmail is String!, documented as "The public-facing contact email address for the shop. Customers will use this email to communicate with the shop owner." — shopify.dev Shop object. This is the correct field; Shop.email is the owner's address and is not customer-facing. No additional access scope is needed beyond the token we already hold.
- [ ] Step 1: Write the failing model round-trip test
Append to server/models/Store.test.js:
js
describe('Store branding.contactEmail', () => {
it('round-trips contactEmail through the schema', () => {
const store = new Store({
shop: 'round-trip.myshopify.com',
branding: { name: 'Round Trip Cards', contactEmail: 'hello@roundtrip.com' }
});
// Fails if the schema line is deleted: strict mode drops undeclared
// sub-doc fields silently on write (§5.2).
expect(store.branding.contactEmail).toBe('hello@roundtrip.com');
});
it('defaults contactEmail to null when Shopify returns none', () => {
const store = new Store({ shop: 'no-email.myshopify.com', branding: { name: 'No Email' } });
expect(store.branding.contactEmail).toBeNull();
});
});- [ ] Step 2: Run it and confirm it fails
Run: npx vitest run server/models/Store.test.js -t contactEmail Expected: FAIL — expected undefined to be 'hello@roundtrip.com'.
- [ ] Step 3: Declare the field
In server/models/Store.js, inside branding, after primaryDomainUrl:
js
// Shopify's Shop.contactEmail — the public-facing address, not the
// owner's Shop.email. Used as Reply-To on the buylist offer email so a
// customer's reply reaches the merchant rather than LGS Forge.
contactEmail: { type: String, default: null },- [ ] Step 4: Run it and confirm it passes
Run: npx vitest run server/models/Store.test.js -t contactEmail Expected: PASS (2 tests).
- [ ] Step 5: Write the failing API test
Append to server/services/shopifyAPI.test.js, matching the file's existing mocking style for graphQL:
js
describe('getShopBranding contactEmail', () => {
it('maps Shop.contactEmail into branding', async () => {
const api = new ShopifyAPI('test.myshopify.com', 'token');
api.graphQL = vi.fn().mockResolvedValue({
shop: { name: 'Test', contactEmail: 'hello@test.com', primaryDomain: { host: 'test.com', url: 'https://test.com' } }
});
const branding = await api.getShopBranding();
expect(branding.contactEmail).toBe('hello@test.com');
expect(api.graphQL.mock.calls[0][0]).toContain('contactEmail');
});
it('nulls contactEmail when the shop has none', async () => {
const api = new ShopifyAPI('test.myshopify.com', 'token');
api.graphQL = vi.fn().mockResolvedValue({ shop: { name: 'Test' } });
expect((await api.getShopBranding()).contactEmail).toBeNull();
});
});- [ ] Step 6: Run it and confirm it fails
Run: npx vitest run server/services/shopifyAPI.test.js -t contactEmail Expected: FAIL — expected undefined to be 'hello@test.com'.
- [ ] Step 7: Add the field to the query and the mapping
In server/services/shopifyAPI.js, getShopBranding:
js
const query = `{
shop {
name
contactEmail
primaryDomain { host url }
}
}`;and in the returned object, after primaryDomainUrl:
js
contactEmail: shop.contactEmail || null,- [ ] Step 8: Run both test files and confirm they pass
Run: npx vitest run server/models/Store.test.js server/services/shopifyAPI.test.js Expected: PASS, no other test in those files regressed.
- [ ] Step 9: Commit
bash
git add server/models/Store.js server/models/Store.test.js server/services/shopifyAPI.js server/services/shopifyAPI.test.js
git commit -m "Store each shop's public contact email for customer replies"Note for the reviewer: existing stores get contactEmail on their next stale-branding refresh (24h, storeBrandingService.refreshBrandingIfStale). No migration; Task 7 treats a missing value as "no Reply-To".
Task 2: The email provider adapter
Files:
- Create:
server/services/emailService.js - Create:
server/services/emailService.test.js - Modify:
.env.example,server/schemas/env.js,.github/workflows/deploy-api.yml
Interfaces:
Consumes: nothing.
Produces:
jssend({ to, fromName, replyTo, subject, html, text }) → Promise<{ status: 'sent', providerMessageId: string|null } | { status: 'failed', error: string } | { status: 'skipped' }>Never throws. Tasks 7 persists this result verbatim onto
order.offerEmail.[ ] Step 1: Write the failing tests
Create server/services/emailService.test.js:
js
import { describe, it, expect, vi, afterEach } from 'vitest';
import { send, _setDeps, _resetDeps } from './emailService.js';
afterEach(() => _resetDeps());
function makeDeps(overrides = {}) {
return {
axios: { post: vi.fn().mockResolvedValue({ data: { id: 'msg_123' } }) },
logger: { warn: vi.fn(), error: vi.fn() },
apiKey: 're_test_key',
from: 'buylist@send.lgsforge.com',
...overrides
};
}
const message = {
to: 'customer@example.com',
fromName: 'Jonathan Cards',
replyTo: 'hello@jonathancards.com',
subject: 'Your buylist offer',
html: '<p>hi</p>',
text: 'hi'
};
describe('emailService.send', () => {
it('posts the message to Resend and returns the provider id', async () => {
const deps = makeDeps();
_setDeps(deps);
const result = await send(message);
expect(result).toEqual({ status: 'sent', providerMessageId: 'msg_123' });
const [url, body, config] = deps.axios.post.mock.calls[0];
expect(url).toBe('https://api.resend.com/emails');
expect(body.to).toEqual(['customer@example.com']);
// Resend's REST API takes snake_case reply_to (the Node SDK's replyTo
// is an SDK-only alias, and we post raw JSON).
expect(body.reply_to).toBe('hello@jonathancards.com');
expect(body.from).toBe('"Jonathan Cards" <buylist@send.lgsforge.com>');
expect(config.headers.Authorization).toBe('Bearer re_test_key');
expect(config.timeout).toBe(5000);
});
it('strips quotes and newlines from fromName so it cannot forge headers', async () => {
const deps = makeDeps();
_setDeps(deps);
await send({ ...message, fromName: 'Evil"\r\nBcc: victim@example.com' });
expect(deps.axios.post.mock.calls[0][1].from)
.toBe('"EvilBcc: victim@example.com" <buylist@send.lgsforge.com>');
});
it('omits reply_to when the store has no contact email', async () => {
const deps = makeDeps();
_setDeps(deps);
await send({ ...message, replyTo: null });
expect(deps.axios.post.mock.calls[0][1]).not.toHaveProperty('reply_to');
});
it('skips without calling the provider when no API key is configured', async () => {
const deps = makeDeps({ apiKey: '' });
_setDeps(deps);
expect(await send(message)).toEqual({ status: 'skipped' });
expect(deps.axios.post).not.toHaveBeenCalled();
});
it('skips when no from address is configured', async () => {
const deps = makeDeps({ from: '' });
_setDeps(deps);
expect(await send(message)).toEqual({ status: 'skipped' });
expect(deps.axios.post).not.toHaveBeenCalled();
});
it('returns failed with the provider message instead of throwing', async () => {
const deps = makeDeps({
axios: { post: vi.fn().mockRejectedValue({ response: { data: { message: 'Domain not verified' } } }) }
});
_setDeps(deps);
expect(await send(message)).toEqual({ status: 'failed', error: 'Domain not verified' });
});
it('returns failed on a timeout', async () => {
const deps = makeDeps({
axios: { post: vi.fn().mockRejectedValue(new Error('timeout of 5000ms exceeded')) }
});
_setDeps(deps);
const result = await send(message);
expect(result.status).toBe('failed');
expect(result.error).toContain('timeout');
});
});- [ ] Step 2: Run and confirm it fails
Run: npx vitest run server/services/emailService.test.js Expected: FAIL — cannot resolve ./emailService.js.
- [ ] Step 3: Write the adapter
Create server/services/emailService.js:
js
/**
* The only thing in the app that talks to an email provider.
*
* Kept deliberately thin and provider-shaped-at-one-point: free transactional
* tiers churn (Postmark and Mailtrap both cut 3,000 -> 500 in Oct 2025;
* SendGrid deleted its free plan outright), so swapping vendors must cost this
* file and one secret, nothing else.
*
* Mirrors notificationService.js: env-gated, never throws, and injectable for
* tests. A caller decides what a failure means; this returns it as data.
*/
'use strict';
const axios = require('axios');
const logger = require('../utils/logger');
const RESEND_ENDPOINT = 'https://api.resend.com/emails';
const SEND_TIMEOUT_MS = 5000;
let _deps = null;
function _setDeps(deps) { _deps = deps; }
function _resetDeps() { _deps = null; }
function getDeps() {
// Env read per call, not at module load: the process may not have loaded
// dotenv yet when this module is first required from a route.
return _deps || {
axios,
logger,
apiKey: process.env.RESEND_API_KEY,
from: process.env.BUYLIST_EMAIL_FROM
};
}
/**
* A display name goes into an RFC 5322 header, so a store name containing a
* quote or CRLF could otherwise inject headers. Strip both rather than escape.
*/
function sanitizeDisplayName(name) {
return String(name).replace(/["\r\n]/g, '').trim();
}
function buildFrom(fromName, address) {
if (!fromName) return address;
const safe = sanitizeDisplayName(fromName);
return safe ? `"${safe}" <${address}>` : address;
}
async function send({ to, fromName, replyTo, subject, html, text }, deps = getDeps()) {
if (!deps.apiKey || !deps.from) {
deps.logger.warn('Email send skipped (RESEND_API_KEY/BUYLIST_EMAIL_FROM not set)', { subject });
return { status: 'skipped' };
}
try {
const response = await deps.axios.post(
RESEND_ENDPOINT,
{
from: buildFrom(fromName, deps.from),
to: [to],
subject,
html,
text,
...(replyTo ? { reply_to: replyTo } : {})
},
{
headers: { Authorization: `Bearer ${deps.apiKey}`, 'Content-Type': 'application/json' },
timeout: SEND_TIMEOUT_MS
}
);
return { status: 'sent', providerMessageId: response.data?.id || null };
} catch (error) {
// Provider message first: "Domain not verified" is the failure that
// will actually happen, and error.message alone hides it.
const detail = error.response?.data?.message || error.message || 'Unknown email error';
deps.logger.error('Email send failed', { subject, error: detail });
return { status: 'failed', error: String(detail).substring(0, 300) };
}
}
module.exports = { send, _setDeps, _resetDeps };- [ ] Step 4: Run and confirm it passes
Run: npx vitest run server/services/emailService.test.js Expected: PASS (7 tests).
- [ ] Step 5: Add the two env keys in all three places (§5.7)
In .env.example, after the Slack block:
# Transactional email (optional - unset disables all outbound email, sends
# become status 'skipped'). Resend: https://resend.com/api-keys
RESEND_API_KEY=
# Verified sending address, e.g. buylist@send.lgsforge.com
BUYLIST_EMAIL_FROM=In server/schemas/env.js, beside the other optional vars:
js
RESEND_API_KEY: z.string().optional(),
BUYLIST_EMAIL_FROM: z.string().email().or(z.literal('')).optional(),In .github/workflows/deploy-api.yml, append to the --set-env-vars value (line 95), keeping the ||| delimiter:
|||RESEND_API_KEY=${{ secrets.RESEND_API_KEY }}|||BUYLIST_EMAIL_FROM=${{ secrets.BUYLIST_EMAIL_FROM }}- [ ] Step 6: Verify the server still boots with the new example keys
Add RESEND_API_KEY= and BUYLIST_EMAIL_FROM= to your local .env (empty is fine), then run: npm run dev:no-worker Expected: server starts. If it exits with "Missing required environment variables", the local .env is missing one of the two keys — dotenv-safe requires every key present in .env.example.
- [ ] Step 7: Commit
bash
git add server/services/emailService.js server/services/emailService.test.js .env.example server/schemas/env.js .github/workflows/deploy-api.yml
git commit -m "Add a transactional email adapter behind one swappable service"Reviewer must confirm: the GitHub Actions secrets RESEND_API_KEY and BUYLIST_EMAIL_FROM are created before this merges, or the next deploy sets them to empty strings and every send silently becomes skipped (§5.7).
Task 3: Schema fields for the re-issued token and the send log
Files:
- Modify:
server/models/BuylistOrder.js(order-level fields, nearclaimTokenHash) - Test:
server/models/BuylistOrder.test.js(create if absent)
Interfaces:
Consumes: nothing.
Produces:
order.claimTokenExpiresAt(Date),order.offerEmail({ sentAt: Date, to: String, providerMessageId: String, status: String, error: String, attempts: Number }). Tasks 4, 5, 7 and 8 all read or write these.[ ] Step 1: Write the failing round-trip test
Create server/models/BuylistOrder.test.js (or append a describe if it exists):
js
/**
* Schema-shape tests. Service tests build plain `{ offerEmail: {...} }` object
* literals and never touch mongoose.Schema, so they pass whether or not these
* fields are declared. Strict mode drops undeclared sub-doc fields on write
* with no error (§5.2) — that is what these catch.
*/
import { describe, it, expect, beforeEach } from 'vitest';
let BuylistOrder;
beforeEach(async () => {
BuylistOrder = (await import('./BuylistOrder.js')).default;
});
describe('BuylistOrder offer email fields', () => {
const base = {
shop: 'test.myshopify.com',
source: 'storefront',
customer: { email: 'customer@example.com' },
lines: []
};
it('round-trips claimTokenExpiresAt', () => {
const expires = new Date('2026-09-24T00:00:00Z');
const order = new BuylistOrder({ ...base, claimTokenExpiresAt: expires });
expect(order.claimTokenExpiresAt).toEqual(expires);
});
it('round-trips every offerEmail field', () => {
const sentAt = new Date('2026-08-25T12:00:00Z');
const order = new BuylistOrder({
...base,
offerEmail: {
sentAt,
to: 'customer@example.com',
providerMessageId: 'msg_123',
status: 'sent',
error: null,
attempts: 1
}
});
expect(order.offerEmail.sentAt).toEqual(sentAt);
expect(order.offerEmail.to).toBe('customer@example.com');
expect(order.offerEmail.providerMessageId).toBe('msg_123');
expect(order.offerEmail.status).toBe('sent');
expect(order.offerEmail.attempts).toBe(1);
});
it('rejects a status outside the enum', () => {
const order = new BuylistOrder({ ...base, offerEmail: { status: 'delivered' } });
expect(order.validateSync()?.errors?.['offerEmail.status']).toBeDefined();
});
});- [ ] Step 2: Run and confirm it fails
Run: npx vitest run server/models/BuylistOrder.test.js Expected: FAIL — expected undefined to equal Date.
- [ ] Step 3: Declare the fields
In server/models/BuylistOrder.js, immediately after claimTokenHash:
js
// When the current claim token stops working. Set when a token is minted
// at send-offer; absent on orders whose token predates expiry, which stay
// valid forever rather than being retroactively killed.
claimTokenExpiresAt: Date,
// Outcome of the offer notification. Declared field by field (§5.2).
// 'sent' means the provider accepted it, NOT that it arrived — we have no
// bounce webhook yet (see the spec's Risks). 'skipped' means no API key is
// configured, which is how local dev behaves and is not an error.
offerEmail: {
sentAt: Date,
to: String,
providerMessageId: String,
status: { type: String, enum: ['sent', 'failed', 'skipped'] },
error: String,
// Increments on every resend, so "we emailed this person four times"
// is answerable from the order.
attempts: { type: Number, default: 0 }
},- [ ] Step 4: Run and confirm it passes
Run: npx vitest run server/models/BuylistOrder.test.js Expected: PASS (3 tests).
- [ ] Step 5: Commit
bash
git add server/models/BuylistOrder.js server/models/BuylistOrder.test.js
git commit -m "Record claim token expiry and offer email delivery on buy orders"Task 4: Expire claim tokens
Files:
- Modify:
server/services/publicBuylistService.js:174-186(findOrderByClaim) and its two callers - Test:
server/services/publicBuylistService.test.js
Interfaces:
- Consumes:
order.claimTokenExpiresAtfrom Task 3. - Produces:
getPublicOrderByClaim/actOnPublicOrderreturn{ error: 'This offer link has expired', status: 410 }for a valid-but-expired token. Task 9's UI copy references it.
A 410 is only reachable by someone already holding a valid token, so it leaks nothing an attacker didn't have. A wrong token stays 404, unchanged.
- [ ] Step 1: Write the failing tests
Append to server/services/publicBuylistService.test.js:
js
describe('claim token expiry', () => {
const futureOrder = (extra = {}) => ({
_id: { toString: () => 'order1' },
status: 'offer_sent',
claimTokenHash: 'b'.repeat(64),
customer: { email: 'c@example.com' },
lines: [],
payout: {},
createdAt: new Date(),
save: vi.fn(),
...extra
});
it('rejects a token whose expiry has passed with 410', async () => {
const deps = makeDeps({
BuylistOrder: { findOne: vi.fn().mockResolvedValue(
futureOrder({ claimTokenExpiresAt: new Date(Date.now() - 1000) })
) }
});
_setDeps(deps);
const result = await getPublicOrderByClaim('s.myshopify.com', 'order1', 'a'.repeat(64));
expect(result).toEqual({ error: 'This offer link has expired', status: 410 });
});
it('accepts a token whose expiry is in the future', async () => {
const deps = makeDeps({
BuylistOrder: { findOne: vi.fn().mockResolvedValue(
futureOrder({ claimTokenExpiresAt: new Date(Date.now() + 60_000) })
) }
});
_setDeps(deps);
expect((await getPublicOrderByClaim('s.myshopify.com', 'order1', 'a'.repeat(64))).error).toBeUndefined();
});
it('treats an order with no expiry as non-expiring', async () => {
const deps = makeDeps({ BuylistOrder: { findOne: vi.fn().mockResolvedValue(futureOrder()) } });
_setDeps(deps);
expect((await getPublicOrderByClaim('s.myshopify.com', 'order1', 'a'.repeat(64))).error).toBeUndefined();
});
it('refuses to act on an expired token', async () => {
const deps = makeDeps({
BuylistOrder: { findOne: vi.fn().mockResolvedValue(
futureOrder({ claimTokenExpiresAt: new Date(Date.now() - 1000) })
) }
});
_setDeps(deps);
const result = await actOnPublicOrder('s.myshopify.com', 'order1', 'a'.repeat(64), 'accept');
expect(result.status).toBe(410);
});
});Note: makeDeps in this file already stubs hashClaimToken to return 'b'.repeat(64), which is why these fixtures set claimTokenHash to the same value — the timing-safe compare then matches.
- [ ] Step 2: Run and confirm it fails
Run: npx vitest run server/services/publicBuylistService.test.js -t "claim token expiry" Expected: FAIL — expired token returns the order instead of a 410.
- [ ] Step 3: Separate "no match" from "expired"
In server/services/publicBuylistService.js, change findOrderByClaim to report why it refused, and update both callers:
js
/**
* @returns {{order: Object}|{error: string, status: number}} — an expired token
* is distinguished from a wrong one so the portal can tell the customer to ask
* for a new link. Only someone already holding a valid token can see the 410,
* so this leaks nothing to a guesser.
*/
async function findOrderByClaim(shop, orderId, token, deps) {
const order = await deps.BuylistOrder.findOne({ _id: orderId, shop });
if (!order || !order.claimTokenHash) return { error: 'Order not found', status: 404 };
const providedHash = Buffer.from(deps.hashClaimToken(token));
const storedHash = Buffer.from(order.claimTokenHash);
// Timing-safe compare: a naive === would let response timing leak a
// partial hash match to a scripted guesser probing this public endpoint.
if (providedHash.length !== storedHash.length || !crypto.timingSafeEqual(providedHash, storedHash)) {
return { error: 'Order not found', status: 404 };
}
// Absent on orders minted before expiry existed — those stay valid.
if (order.claimTokenExpiresAt && order.claimTokenExpiresAt.getTime() <= Date.now()) {
return { error: 'This offer link has expired', status: 410 };
}
return { order };
}
async function getPublicOrderByClaim(shop, orderId, token, deps = getDeps()) {
const found = await findOrderByClaim(shop, orderId, token, deps);
if (found.error) return found;
return { order: toPublicOrder(found.order) };
}
async function actOnPublicOrder(shop, orderId, token, action, deps = getDeps()) {
const found = await findOrderByClaim(shop, orderId, token, deps);
if (found.error) return found;
const order = found.order;
if (order.status !== 'offer_sent') {
return { error: `Cannot ${action} an order with status ${order.status}`, status: 409 };
}
order.status = action === 'accept' ? 'accepted' : 'declined';
await order.save();
deps.logger.info(`Customer ${action}ed buylist order`, { shop, orderId: order._id });
return { order: toPublicOrder(order) };
}- [ ] Step 4: Run the whole file and confirm nothing regressed
Run: npx vitest run server/services/publicBuylistService.test.js Expected: PASS — including the pre-existing wrong-token and not-found tests, which must still be 404.
- [ ] Step 5: Commit
bash
git add server/services/publicBuylistService.js server/services/publicBuylistService.test.js
git commit -m "Expire buylist claim links and tell the customer when one has lapsed"Task 5: The offer email builder
Files:
- Create:
server/services/buylistOfferEmail.js - Create:
server/services/buylistOfferEmail.test.js
Interfaces:
- Consumes:
formatOrderNumberfromserver/services/buylistNumberService.js(returns'#00123'(pad width 5) ornullfor legacy orders). - Produces:jsTask 7 passes this straight into
buildOfferEmail({ order, branding, claimUrl }) → { subject: string, html: string, text: string, fromName: string }emailService.send.
Both CTAs link to the same portal URL. A GET link that accepted or declined would be fired by Gmail's and Outlook's link prefetchers, silently accepting offers nobody read — the customer must click through and POST from the page.
- [ ] Step 1: Write the failing tests
Create server/services/buylistOfferEmail.test.js:
js
import { describe, it, expect } from 'vitest';
import { buildOfferEmail } from './buylistOfferEmail.js';
const order = {
orderNumber: 123,
customer: { email: 'customer@example.com', name: 'Sam' },
payout: { method: 'store_credit', cashTotal: 40, creditTotal: 52, creditBonus: 12, marketTotal: 80 },
lines: [
{ cardName: 'Black Lotus', quantity: 1, included: true },
{ cardName: 'Mox Pearl', quantity: 2, included: true },
{ cardName: 'Shivan Dragon', quantity: 1, included: false }
]
};
const branding = { name: "Jonathan's Cards", contactEmail: 'hello@jc.com' };
const claimUrl = 'https://app.lgsforge.com/portal/jc.myshopify.com/buylist/orders/abc?token=deadbeef';
describe('buildOfferEmail', () => {
it('names the shop and the order in the subject', () => {
const { subject } = buildOfferEmail({ order, branding, claimUrl });
expect(subject).toBe("Your buylist offer from Jonathan's Cards (#00123)");
});
it('falls back to a subject without a number for legacy orders', () => {
const { subject } = buildOfferEmail({ order: { ...order, orderNumber: null }, branding, claimUrl });
expect(subject).toBe("Your buylist offer from Jonathan's Cards");
});
it('uses the shop name as the sender display name', () => {
expect(buildOfferEmail({ order, branding, claimUrl }).fromName).toBe("Jonathan's Cards");
});
it('shows the offered total for the chosen payout method', () => {
const { html, text } = buildOfferEmail({ order, branding, claimUrl });
expect(html).toContain('$52.00');
expect(text).toContain('$52.00');
expect(html).toContain('store credit');
});
it('shows the cash total when cash is the chosen method', () => {
const cashOrder = { ...order, payout: { ...order.payout, method: 'cash' } };
expect(buildOfferEmail({ order: cashOrder, branding, claimUrl }).html).toContain('$40.00');
});
it('counts only included lines', () => {
const { text } = buildOfferEmail({ order, branding, claimUrl });
expect(text).toContain('3 cards');
});
it('puts the claim link behind both calls to action', () => {
const { html } = buildOfferEmail({ order, branding, claimUrl });
const links = [...html.matchAll(/href="([^"]+)"/g)].map((m) => m[1]);
expect(links.filter((l) => l === claimUrl)).toHaveLength(2);
// Neither CTA may be a mutating GET: mail clients prefetch links.
expect(html).not.toMatch(/href="[^"]*\/(accept|decline)"/);
});
it('escapes HTML in a shop or card name', () => {
const evil = { ...branding, name: '<script>alert(1)</script>' };
const { html } = buildOfferEmail({ order, branding: evil, claimUrl });
expect(html).not.toContain('<script>');
expect(html).toContain('<script>');
});
it('falls back to a generic shop name when branding is absent', () => {
const { subject, fromName } = buildOfferEmail({ order, branding: null, claimUrl });
expect(subject).toBe('Your buylist offer (#00123)');
expect(fromName).toBe('LGS Forge');
});
});- [ ] Step 2: Run and confirm it fails
Run: npx vitest run server/services/buylistOfferEmail.test.js Expected: FAIL — cannot resolve ./buylistOfferEmail.js.
- [ ] Step 3: Write the builder
Create server/services/buylistOfferEmail.js:
js
/**
* Turns a buy order into the customer-facing offer email. Pure — no I/O, no
* provider knowledge — so it can be rendered to a file and eyeballed, and so
* emailService stays the single place that knows a vendor exists.
*/
'use strict';
const { formatOrderNumber } = require('./buylistNumberService');
const FALLBACK_SHOP_NAME = 'LGS Forge';
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
function formatMoney(amount) {
return `$${Number(amount || 0).toFixed(2)}`;
}
function payoutSummary(payout) {
const isCredit = payout?.method === 'store_credit';
return {
label: isCredit ? 'store credit' : 'cash',
total: formatMoney(isCredit ? payout?.creditTotal : payout?.cashTotal)
};
}
function buildOfferEmail({ order, branding, claimUrl }) {
const shopName = branding?.name || FALLBACK_SHOP_NAME;
const number = formatOrderNumber(order.orderNumber);
const included = (order.lines || []).filter((line) => line.included !== false);
const cardCount = included.reduce((sum, line) => sum + (line.quantity || 0), 0);
const { label, total } = payoutSummary(order.payout);
const subject = [
'Your buylist offer',
branding?.name ? `from ${shopName}` : null,
number ? `(${number})` : null
].filter(Boolean).join(' ');
const text = [
`${shopName} has reviewed your buylist submission${number ? ` ${number}` : ''}.`,
'',
`Offer: ${total} in ${label} for ${cardCount} cards.`,
'',
'Accept or decline your offer here:',
claimUrl,
'',
'This link expires in 30 days.'
].join('\n');
// Both buttons deliberately point at the same page. A GET link that
// accepted or declined would be fired by Gmail/Outlook link prefetchers.
const html = `<!doctype html>
<html><body style="font-family: system-ui, sans-serif; color: #111;">
<p>${escapeHtml(shopName)} has reviewed your buylist submission${number ? ` ${escapeHtml(number)}` : ''}.</p>
<p style="font-size: 20px;"><strong>${escapeHtml(total)}</strong> in ${escapeHtml(label)} for ${cardCount} cards.</p>
<p>
<a href="${escapeHtml(claimUrl)}" style="background:#C4A1FF;border:3px solid #000;padding:12px 20px;color:#000;text-decoration:none;font-weight:700;">Accept offer</a>
<a href="${escapeHtml(claimUrl)}" style="border:3px solid #000;padding:12px 20px;color:#000;text-decoration:none;font-weight:700;">Decline offer</a>
</p>
<p style="color:#555;font-size:13px;">Both buttons open your offer page, where you can review every card before deciding. This link expires in 30 days.</p>
</body></html>`;
return { subject, html, text, fromName: shopName };
}
module.exports = { buildOfferEmail };- [ ] Step 4: Run and confirm it passes
Run: npx vitest run server/services/buylistOfferEmail.test.js Expected: PASS (9 tests).
- [ ] Step 5: Eyeball the rendered email
bash
node -e "const {buildOfferEmail}=require('./server/services/buylistOfferEmail');require('fs').writeFileSync('offer-preview.html',buildOfferEmail({order:{orderNumber:123,payout:{method:'store_credit',creditTotal:52,cashTotal:40},lines:[{cardName:'Black Lotus',quantity:1,included:true}]},branding:{name:\"Jonathan's Cards\"},claimUrl:'https://example.com'}).html)"Open offer-preview.html in a browser, confirm it reads correctly, then delete it. Do not commit it.
- [ ] Step 6: Commit
bash
git add server/services/buylistOfferEmail.js server/services/buylistOfferEmail.test.js
git commit -m "Compose the customer's buylist offer email"Task 6: Send on send-offer, and add resend-offer
Files:
- Modify:
server/routes/buylist.js:571-593(send-offer), plus a newresend-offerroute - Create:
server/routes/buylist.sendOffer.test.js
Interfaces:
- Consumes:
generateClaimToken,hashClaimToken(server/utils/crypto.js),buildOfferEmail(Task 5),emailService.send(Task 2),Store.branding.contactEmail(Task 1), the schema fields (Task 3). - Produces: exported
handleSendOffer(req, res)andhandleResendOffer(req, res); both respond200 { order, email: { status, error? }, claimUrl }. Task 8's UI consumes all three fields.
Test harness note: route tests in this repo are per-feature files (buylist.settle.test.js, buylist.intake.test.js — there is no buylist.test.js) and call exported named handlers directly. They obtain the module via createRequire, because await import() of a CJS file returns a synthetic namespace whose getters only mirror the singleton the handler's inner require() sees — spying on the imported namespace silently does nothing. Copy the harness from server/routes/buylist.settle.test.js:9-31.
- [ ] Step 1: Write the failing tests
Create server/routes/buylist.sendOffer.test.js:
js
/**
* POST /api/buylist/orders/:id/send-offer and .../resend-offer — mint a fresh
* claim link, email it, and never let a dead provider block the merchant.
*
* createRequire for the same module-cache reason as buylist.settle.test.js.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createRequire } from 'module';
const require_ = createRequire(import.meta.url);
const { handleSendOffer, handleResendOffer } = require_('./buylist.js');
const BuylistOrder = require_('../models/BuylistOrder.js');
const emailService = require_('../services/emailService.js');
const cryptoUtils = require_('../utils/crypto.js');
const TOKEN = 't'.repeat(64);
function makeRes() {
return { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis() };
}
function makeReq(overrides = {}) {
return {
params: { id: 'order-1' },
body: {},
store: {
shop: 'test-store.myshopify.com',
branding: { name: "Jonathan's Cards", contactEmail: 'hello@jc.com' }
},
...overrides
};
}
function makeOrder(overrides = {}) {
return {
_id: 'order-1',
shop: 'test-store.myshopify.com',
orderNumber: 123,
status: 'pending',
claimTokenHash: 'hash-from-intake',
customer: { email: 'customer@example.com' },
lines: [{ cardName: 'Black Lotus', quantity: 1, included: true }],
payout: { method: 'store_credit', creditTotal: 52, cashTotal: 40 },
save: vi.fn().mockResolvedValue(undefined),
...overrides
};
}
beforeEach(() => {
vi.spyOn(cryptoUtils, 'generateClaimToken').mockReturnValue(TOKEN);
vi.spyOn(cryptoUtils, 'hashClaimToken').mockImplementation((t) => `hash:${t}`);
vi.spyOn(emailService, 'send').mockResolvedValue({ status: 'sent', providerMessageId: 'msg_1' });
process.env.APP_DEPLOYMENT_URL = 'https://app.lgsforge.com';
});
afterEach(() => { vi.restoreAllMocks(); });
describe('handleSendOffer', () => {
let res;
beforeEach(() => { res = makeRes(); });
it('404s when the order does not belong to this shop', async () => {
vi.spyOn(BuylistOrder, 'findOne').mockResolvedValue(null);
await handleSendOffer(makeReq(), res);
expect(res.status).toHaveBeenCalledWith(404);
});
it('409s on an order that is not pending', async () => {
vi.spyOn(BuylistOrder, 'findOne').mockResolvedValue(makeOrder({ status: 'accepted' }));
await handleSendOffer(makeReq(), res);
expect(res.status).toHaveBeenCalledWith(409);
});
it('re-mints the claim token rather than reusing the one from intake', async () => {
const order = makeOrder();
vi.spyOn(BuylistOrder, 'findOne').mockResolvedValue(order);
await handleSendOffer(makeReq(), res);
expect(order.claimTokenHash).toBe(`hash:${TOKEN}`);
expect(order.claimTokenExpiresAt.getTime()).toBeGreaterThan(Date.now());
expect(order.save).toHaveBeenCalled();
});
it('emails the customer a link carrying the plaintext token', async () => {
vi.spyOn(BuylistOrder, 'findOne').mockResolvedValue(makeOrder());
await handleSendOffer(makeReq(), res);
const [message] = emailService.send.mock.calls[0];
expect(message.to).toBe('customer@example.com');
expect(message.replyTo).toBe('hello@jc.com');
expect(message.subject).toContain("Jonathan's Cards");
expect(res.json.mock.calls[0][0].claimUrl).toBe(
`https://app.lgsforge.com/portal/test-store.myshopify.com/buylist/orders/order-1?token=${TOKEN}`
);
});
it('omits reply-to when the store has no contact email yet', async () => {
vi.spyOn(BuylistOrder, 'findOne').mockResolvedValue(makeOrder());
const req = makeReq({ store: { shop: 'test-store.myshopify.com', branding: { name: 'X' } } });
await handleSendOffer(req, res);
expect(emailService.send.mock.calls[0][0].replyTo).toBeNull();
});
it('records a successful send on the order', async () => {
const order = makeOrder();
vi.spyOn(BuylistOrder, 'findOne').mockResolvedValue(order);
await handleSendOffer(makeReq(), res);
expect(order.status).toBe('offer_sent');
expect(order.offerEmail).toMatchObject({
status: 'sent', to: 'customer@example.com', providerMessageId: 'msg_1', attempts: 1
});
expect(res.json.mock.calls[0][0].email).toEqual({ status: 'sent' });
});
it('still offers the order when the email fails', async () => {
const order = makeOrder();
vi.spyOn(BuylistOrder, 'findOne').mockResolvedValue(order);
emailService.send.mockResolvedValue({ status: 'failed', error: 'Domain not verified' });
await handleSendOffer(makeReq(), res);
expect(res.status).not.toHaveBeenCalled();
expect(order.status).toBe('offer_sent');
expect(order.offerEmail.status).toBe('failed');
expect(res.json.mock.calls[0][0].email).toEqual({ status: 'failed', error: 'Domain not verified' });
});
it('records skipped when no provider is configured', async () => {
const order = makeOrder();
vi.spyOn(BuylistOrder, 'findOne').mockResolvedValue(order);
emailService.send.mockResolvedValue({ status: 'skipped' });
await handleSendOffer(makeReq(), res);
expect(order.status).toBe('offer_sent');
expect(order.offerEmail.status).toBe('skipped');
});
});
describe('handleResendOffer', () => {
let res;
beforeEach(() => { res = makeRes(); });
it('409s unless an offer is already out', async () => {
vi.spyOn(BuylistOrder, 'findOne').mockResolvedValue(makeOrder({ status: 'pending' }));
await handleResendOffer(makeReq(), res);
expect(res.status).toHaveBeenCalledWith(409);
});
it('re-mints so the previous link stops working, and counts the attempt', async () => {
const order = makeOrder({
status: 'offer_sent',
claimTokenHash: 'hash:previous',
offerEmail: { attempts: 1, status: 'failed' }
});
vi.spyOn(BuylistOrder, 'findOne').mockResolvedValue(order);
await handleResendOffer(makeReq(), res);
expect(order.claimTokenHash).toBe(`hash:${TOKEN}`);
expect(order.offerEmail.attempts).toBe(2);
expect(order.status).toBe('offer_sent');
});
});- [ ] Step 2: Run and confirm they fail
Run: npx vitest run server/routes/buylist.sendOffer.test.js Expected: FAIL — handleSendOffer is not a function (the handler is still an inline anonymous callback).
- [ ] Step 3: Extract the shared send and wire both routes
In server/routes/buylist.js, add above the send-offer route:
js
const CLAIM_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000;
/**
* Mint a fresh claim link and email it. Shared by send-offer and resend-offer
* so the two can't drift.
*
* Re-minting invalidates the token issued at order creation. That is the point:
* that token was returned exactly once, in a response body the customer no
* longer has, which is why the offer was unreachable (#642).
*
* Never throws. A dead provider must not block a merchant from offering — the
* order moves to offer_sent either way and the response carries the link for
* the merchant to pass on by hand.
*/
async function issueAndEmailOffer(order, store) {
const { generateClaimToken, hashClaimToken } = require('../utils/crypto');
const { buildOfferEmail } = require('../services/buylistOfferEmail');
const emailService = require('../services/emailService');
const claimToken = generateClaimToken();
order.claimTokenHash = hashClaimToken(claimToken);
order.claimTokenExpiresAt = new Date(Date.now() + CLAIM_TOKEN_TTL_MS);
// Same origin serves the portal page and rewrites /api to Cloud Run
// (firebase.json), so one base URL covers the link and the calls it makes.
const base = process.env.APP_DEPLOYMENT_URL || 'http://localhost:3000';
const claimUrl = `${base}/portal/${order.shop}/buylist/orders/${order._id}?token=${claimToken}`;
const message = buildOfferEmail({ order, branding: store.branding, claimUrl });
const result = await emailService.send({
to: order.customer.email,
fromName: message.fromName,
// Absent until the store's branding refreshes (Task 1); no Reply-To is
// better than one that lands in our inbox.
replyTo: store.branding?.contactEmail || null,
subject: message.subject,
html: message.html,
text: message.text
});
order.offerEmail = {
sentAt: new Date(),
to: order.customer.email,
providerMessageId: result.providerMessageId || null,
status: result.status,
error: result.error || null,
attempts: (order.offerEmail?.attempts || 0) + 1
};
return { claimUrl, email: { status: result.status, ...(result.error ? { error: result.error } : {}) } };
}Then convert the existing inline router.post('/buylist/orders/:id/send-offer', async (req, res) => { … }) (line 571) into a named, exported handler — the tests call it directly, matching handleSettleOrder's pattern:
js
/**
* POST /api/buylist/orders/:id/send-offer
* Merchant has reviewed a customer-submitted (source !== 'merchant') order
* and is ready for the customer to see + confirm it on their claim-link
* status page. Only a 'pending' order can move to 'offer_sent'.
*/
async function handleSendOffer(req, res) {
try {
const BuylistOrder = require('../models/BuylistOrder');
const order = await BuylistOrder.findOne({ _id: req.params.id, shop: req.store.shop });
if (!order) {
return res.status(404).json({ error: 'Buy order not found' });
}
if (order.status !== 'pending') {
return res.status(409).json({ error: `Cannot send an offer for an order with status ${order.status}` });
}
order.status = 'offer_sent';
const sent = await issueAndEmailOffer(order, req.store);
await order.save();
logger.info('Sent buylist offer to customer', {
shop: req.store.shop, orderId: order._id, email: sent.email.status
});
res.json({ order, email: sent.email, claimUrl: sent.claimUrl });
} catch (error) {
logger.error('Failed to send buylist offer', { error: error.message, orderId: req.params.id });
res.status(500).json({ error: 'Failed to send offer' });
}
}
/**
* POST /api/buylist/orders/:id/resend-offer
* Re-mint and re-send. For a bad address corrected at the counter, or a
* customer who deleted the email. Only meaningful once an offer is out.
*/
async function handleResendOffer(req, res) {
try {
const BuylistOrder = require('../models/BuylistOrder');
const order = await BuylistOrder.findOne({ _id: req.params.id, shop: req.store.shop });
if (!order) {
return res.status(404).json({ error: 'Buy order not found' });
}
if (order.status !== 'offer_sent') {
return res.status(409).json({ error: `Cannot resend an offer for an order with status ${order.status}` });
}
const sent = await issueAndEmailOffer(order, req.store);
await order.save();
logger.info('Resent buylist offer to customer', {
shop: req.store.shop, orderId: order._id, email: sent.email.status
});
res.json({ order, email: sent.email, claimUrl: sent.claimUrl });
} catch (error) {
logger.error('Failed to resend buylist offer', { error: error.message, orderId: req.params.id });
res.status(500).json({ error: 'Failed to resend offer' });
}
}
router.post('/buylist/orders/:id/send-offer', handleSendOffer);
router.post('/buylist/orders/:id/resend-offer', handleResendOffer);- [ ] Step 3b: Export both handlers for the tests
At the bottom of server/routes/buylist.js, beside the existing handler exports:
js
module.exports.handleSendOffer = handleSendOffer;
module.exports.handleResendOffer = handleResendOffer;- [ ] Step 4: Run and confirm they pass
Run: npx vitest run server/routes/buylist.sendOffer.test.js && npx vitest run server/routes/buylist.settle.test.js server/routes/buylist.updateOrder.test.js Expected: PASS — the new file, and the neighbouring route tests that share buylist.js must be unregressed by the handler extraction.
- [ ] Step 5: Confirm no identity default crept in (§5.5)
Run: git diff main -- server/routes/buylist.js | grep -nE "\|\| 'mtg'|\?\? 'mtg'" Expected: no output.
- [ ] Step 6: Commit
bash
git add server/routes/buylist.js server/routes/buylist.sendOffer.test.js
git commit -m "Email the customer a working offer link when the merchant sends an offer"Task 7: Erase the emailed address on GDPR redaction
Files:
- Modify:
server/services/gdprService.js:163 - Test:
server/services/gdprService.test.js
Interfaces:
- Consumes:
order.offerEmail.to,order.claimTokenExpiresAt(Task 3). - Produces: nothing downstream.
offerEmail.to is a second copy of the customer's email address. customers/redact currently unsets claimTokenHash only, so the copy would survive erasure.
- [ ] Step 1: Write the failing test
Append to server/services/gdprService.test.js:
js
it('unsets the emailed offer address and token expiry on redaction', async () => {
const updateMany = vi.fn().mockResolvedValue({ modifiedCount: 1 });
_setDeps({ BuylistOrder: { updateMany } });
// This file imports the module as a namespace (`const gdprService = await
// import('./gdprService.js')`) — call through it, not as a bare binding.
await gdprService.redactCustomerData('test.myshopify.com', { email: 'customer@example.com' });
const unset = updateMany.mock.calls[0][1].$unset;
expect(unset).toHaveProperty('offerEmail.to');
expect(unset).toHaveProperty('claimTokenExpiresAt');
expect(unset).toHaveProperty('claimTokenHash');
});- [ ] Step 2: Run and confirm it fails
Run: npx vitest run server/services/gdprService.test.js -t redaction Expected: FAIL — offerEmail.to is not in $unset.
- [ ] Step 3: Extend the unset
js
$unset: {
'customer.name': '',
'customer.shopifyCustomerId': '',
claimTokenHash: '',
claimTokenExpiresAt: '',
// A second copy of the customer's address; erasure must take
// both. The rest of offerEmail is delivery metadata about a
// business record and stays.
'offerEmail.to': ''
}- [ ] Step 4: Run and confirm it passes
Run: npx vitest run server/services/gdprService.test.js Expected: PASS.
- [ ] Step 5: Commit
bash
git add server/services/gdprService.js server/services/gdprService.test.js
git commit -m "Erase the emailed offer address when a customer requests deletion"Task 8: Show the merchant the link and the send result
Files:
- Modify:
client/src/pages/buylist/BuylistReviewPage.jsx(theacthandler ~line 258; theisPending && source !== 'merchant'block ~line 593; theisOfferSentblock ~line 600) - Test:
client/src/pages/buylist/BuylistReviewPage.test.jsx
Interfaces:
- Consumes:
{ order, email: { status, error? }, claimUrl }from Task 6. - Produces: nothing downstream.
The existing act(action) already POSTs to /buylist/orders/${id}/${action}, so act('resend-offer') needs no new API helper — only the extra response fields must be captured.
- [ ] Step 1: Write the failing tests
Append these to the existing describe('BuylistReviewPage — storefront orders', …) block in client/src/pages/buylist/BuylistReviewPage.test.jsx (line 536), which already defines storefrontPendingOrder and offerSentOrder. That file renders with renderAt(id) and clicks with fireEvent — not userEvent — and gates on await screen.findByText('Lightning Bolt') for load. The fixture customer is jamie@example.com.
jsx
const CLAIM_URL = 'https://app.lgsforge.com/portal/s.myshopify.com/buylist/orders/order-1?token=abc';
it('shows the claim link and confirms the email after sending an offer', async () => {
api.get.mockResolvedValue({ data: { order: storefrontPendingOrder } });
api.post.mockResolvedValue({ data: {
order: { ...storefrontPendingOrder, status: 'offer_sent' },
email: { status: 'sent' },
claimUrl: CLAIM_URL
} });
renderAt('order-1');
await screen.findByText('Lightning Bolt');
fireEvent.click(screen.getByRole('button', { name: /send offer to customer/i }));
expect(await screen.findByText(/emailed jamie@example\.com/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /copy link/i })).toBeInTheDocument();
});
it('tells the merchant to pass the link on when the email fails', async () => {
api.get.mockResolvedValue({ data: { order: storefrontPendingOrder } });
api.post.mockResolvedValue({ data: {
order: { ...storefrontPendingOrder, status: 'offer_sent' },
email: { status: 'failed', error: 'Domain not verified' },
claimUrl: CLAIM_URL
} });
renderAt('order-1');
await screen.findByText('Lightning Bolt');
fireEvent.click(screen.getByRole('button', { name: /send offer to customer/i }));
expect(await screen.findByText(/couldn't be emailed/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /copy link/i })).toBeInTheDocument();
});
it('offers a resend once an offer is out', async () => {
api.get.mockResolvedValue({ data: { order: offerSentOrder } });
api.post.mockResolvedValue({ data: { order: offerSentOrder, email: { status: 'sent' }, claimUrl: CLAIM_URL } });
renderAt('order-1');
await screen.findByText('Lightning Bolt');
fireEvent.click(screen.getByRole('button', { name: /resend email/i }));
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/buylist/orders/order-1/resend-offer'));
});Note: the panel renders inside the isOfferSent block, so on the send-offer path it appears only after adoptOrder flips the status — which is why each test asserts after the click rather than before.
- [ ] Step 2: Run and confirm they fail
Run: npx vitest run --config vitest.client.config.js client/src/pages/buylist/BuylistReviewPage.test.jsx Expected: FAIL — no copy-link button exists.
- [ ] Step 3: Capture the new response fields
Add state beside the existing declarations (~line 141):
jsx
const [offerLink, setOfferLink] = useState(null);
const [emailResult, setEmailResult] = useState(null);and record them in act (~line 262):
jsx
const { data } = await api.post(`/buylist/orders/${id}/${action}`);
// Only send-offer/resend-offer return these; leave the previous values
// alone for accept/decline/settle rather than blanking the panel.
if (data.claimUrl) setOfferLink(data.claimUrl);
if (data.email) setEmailResult(data.email);
adoptOrder(data.order);- [ ] Step 4: Render the panel
Add inside the isOfferSent block, above the existing Accept/Decline buttons:
jsx
{emailResult && (
<p className="text-sm">
{emailResult.status === 'sent' && `Emailed ${order.customer.email}.`}
{emailResult.status === 'failed' && `This offer couldn't be emailed (${emailResult.error}). Send the link below to the customer yourself.`}
{emailResult.status === 'skipped' && 'Email isn\'t configured, so nothing was sent. Send the link below to the customer yourself.'}
</p>
)}
{offerLink && (
<div className="flex items-center gap-2">
<code className="truncate text-xs">{offerLink}</code>
<Button variant="outline" onClick={() => navigator.clipboard.writeText(offerLink)}>Copy link</Button>
</div>
)}
<Button variant="outline" onClick={() => act('resend-offer')} disabled={acting}>Resend email</Button>- [ ] Step 5: Run and confirm they pass
Run: npx vitest run --config vitest.client.config.js client/src/pages/buylist/BuylistReviewPage.test.jsx Expected: PASS, including the file's pre-existing tests.
- [ ] Step 6: Commit
bash
git add client/src/pages/buylist/BuylistReviewPage.jsx client/src/pages/buylist/BuylistReviewPage.test.jsx
git commit -m "Show merchants the customer's offer link and whether the email sent"Task 9: Full verification
Files: none — this task only runs commands and reports.
- [ ] Step 1: Full server suite
Run: npm test Expected: exit 0.
- [ ] Step 2: Client suite and build
Run: npm run test:client && npm run build Expected: both exit 0.
- [ ] Step 3: Lint
Run: npm run lint Expected: exit 0 with no new warnings. Two warnings pre-date this work. If the count looks wrong, get the baseline with git stash-free comparison: git worktree add /tmp/lint-base main && (cd /tmp/lint-base && npm run lint). Never git stash in a worktree — the stash stack is shared across all of them and a concurrent session can pop your entry.
- [ ] Step 4: Read the coverage table yourself
Run: npm run test:coverage Expected: every file this plan touched reports ≥70% on all four metrics. The command exits 0 regardless — the Vitest 4 threshold gate is inoperative. Read the per-file rows for: emailService.js, buylistOfferEmail.js, publicBuylistService.js, routes/buylist.js, gdprService.js.
- [ ] Step 5: Confirm no raw Shopify calls were introduced
Run: git diff main --name-only | xargs grep -ln "myshopify.com" | xargs grep -n "axios\|fetch" | grep -v shopifyAPI.js Expected: no output (rule 6 — all Shopify traffic goes through shopifyAPI.js).
- [ ] Step 6: End-to-end against a real inbox
Prerequisite: Task 0 complete — the domain reports verified in Resend — and RESEND_API_KEY + BUYLIST_EMAIL_FROM set in your local .env.
npm run dev, submit a storefront buylist order to the dev store with your own email.- In the merchant dashboard, open the order and click Send Offer to Customer.
- Confirm: the email arrives; the subject names the shop and order; both buttons open the portal status page; the page shows the offer and its Accept/Decline buttons; clicking Accept moves the order to
acceptedin the dashboard. - Click Resend email, confirm a second email arrives and that the first email's link now returns "This offer link has expired" — that is the re-mint working as designed.
- [ ] Step 7: Open the PR
The description must state: §5.1 parity is N/A (nothing per-game); the bounce webhook is knowingly deferred (offerEmail.status: 'sent' means accepted-by-provider, not delivered); and that RESEND_API_KEY / BUYLIST_EMAIL_FROM exist as GitHub Actions secrets and in the Cloud Run env (§5.7).
Deferred, deliberately
Recorded so a later reader doesn't mistake these for oversights:
- Bounce/complaint webhooks (
email.bounced,email.deliveredvia Svix signature verification). Until this lands, an offer emailed to a dead address looks successful. Follow-up PR. ?intent=accept|declineon the claim URL so the portal can pre-highlight the button the customer clicked. Needs client work and buys little; both CTAs land on the same page today.- Per-merchant sending domains. One LGS Forge domain with the merchant as
Reply-Tountil a merchant asks.
