← back to Dw Signup Fulfillment
DEPLOY.md
182 lines
# DEPLOY — dw-signup-fulfillment go-live runbook (path B: sample-locked shared code)
This service is **build-to-ready**. It ships in `DRY_RUN=1` and does nothing
irreversible until you deliberately flip it. Follow these steps IN ORDER. Do not
flip `DRY_RUN=0` until every prior step is green.
> **Wired behavior** (DTD 2026-07-28, 5/5 → path B):
> - **Retail** — on `customers/create`, the service **emails the new customer ONE
> shared code** (`RETAIL_SHARED_CODE`, e.g. `DWSAMPLES3`). That code is a **code
> discount you create ONCE in the Shopify admin**, pointing at the deployed
> **"DW Free Samples" Discount Function**, limited to **one use per customer**.
> The service makes **no Shopify discount write** — it only sends the email.
> Safety comes from the FUNCTION: signed-in non-trade → first 3 **Sample** units
> free; it **never discounts the `$94.42` roll variant**. (This is why the function
> deploy in Step 1 is REQUIRED, not optional — the code without the function behind
> it is just a plain discount with no roll protection.)
> - **Trade** — `POST /trade/apply` → `pending` → reviewed at `/admin/trade` → on
> **approve**: resolve the Shopify customer id (by email if the form didn't supply
> one; **hard-fails** if no such customer exists — never writes a placeholder), tag
> `trade` (unlocks free memos), assign the DW House Account, set `custom.assigned_rep`,
> email rep + applicant.
---
## 0. Pre-flight (still DRY_RUN)
```sh
cd ~/Projects/dw-signup-fulfillment
npm install
node scripts/selftest.js # must print ALL CHECKS PASSED and exit 0
```
Store target is the LIVE prod store (legacy misnomer):
`designer-laboratory-sandbox.myshopify.com`, Admin API `2024-10`.
---
## 1. One-time: deploy the "DW Free Samples" Discount Function (REQUIRED — the safety layer)
This is the roll-protection. The shared code you create in Step 2 must point at it,
or the code offers no roll safety. Deploy it ONCE, interactively (Steve-only — Partner
login):
```sh
cd ~/Projects/Designer-Wallcoverings/shopify/staged/free-samples-function
shopify app config link # DW Partner org + designer-laboratory-sandbox
shopify app deploy # uploads the function; NO discount created yet → zero customer impact
```
> If `shopify app config link` refuses over scope negotiation, confirm
> `shopify.app.toml` `access_scopes` includes `write_discounts` before retrying.
(Optional, informational: read the function id for reference —
`query { shopifyFunctions(first:25){ nodes{ id title apiType } } }` → set
`DISCOUNT_FUNCTION_ID`. The service does not use it on the wired path; the admin
discount is what references the function.)
---
## 2. One-time: create the shared code discount in the Shopify admin
Shopify admin → **Discounts** → create a **code** discount:
- code: **`DWSAMPLES3`** (must match `RETAIL_SHARED_CODE` exactly),
- discount type: the **"DW Free Samples"** app/function discount from Step 1,
- usage limit: **one use per customer**.
The service emails this exact code to every new customer; it never mints codes.
---
## 3. Set env (`.env` — gitignored, never commit/echo real tokens)
```sh
SHOPIFY_FULFILLMENT_TOKEN=<value of SHOPIFY_DRAFT_TOKEN from ~/Projects/secrets-manager/.env, ends a43b>
SHOPIFY_WEBHOOK_SECRET=<signing secret from the webhook registration in Step 5>
RETAIL_SHARED_CODE=DWSAMPLES3 # MUST equal the admin code from Step 2 (else WARN + skip send)
HOUSE_ACCOUNT_EMAIL=info@designerwallcoverings.com
# DISCOUNT_FUNCTION_ID — informational only on the wired path; safe to leave blank.
```
The `…a43b` token carries `write_customers, read_customers, write_discounts,
write_price_rules, write_gift_cards` (verified 2026-07-28 via
`/admin/oauth/access_scopes.json`) — sufficient for tagging, metafields, and the
email-lookup at approve.
---
## 4. Deploy the service (still DRY_RUN)
```sh
pm2 start ecosystem.config.js # DRY_RUN=1 by env in ecosystem
pm2 save
curl -s http://127.0.0.1:9856/healthz # -> {"ok":true,...,"dry_run":true}
```
Expose it at a public HTTPS URL (Kamatera vhost / Cloudflare) so Shopify can POST.
Call that `$PUBLIC_URL`. `/healthz` returns 200 before any auth (fleet rule).
---
## 5. Register the customers/create webhook (Admin API)
DRY_RUN-gated in `lib/shopify.js`; do it explicitly at go-live.
```sh
SHOP=designer-laboratory-sandbox.myshopify.com
TOKEN=<SHOPIFY_FULFILLMENT_TOKEN>
PUBLIC_URL=https://signup.designerwallcoverings.com # <-- your real URL
curl -s -X POST "https://$SHOP/admin/api/2024-10/webhooks.json" \
-H "X-Shopify-Access-Token: $TOKEN" -H "Content-Type: application/json" \
-d "{\"webhook\":{\"topic\":\"customers/create\",\"address\":\"$PUBLIC_URL/webhooks/customers/create\",\"format\":\"json\"}}"
```
Record the returned webhook **id** (rollback). Put its signing secret into
`SHOPIFY_WEBHOOK_SECRET` (Step 3).
---
## 6. Flip DRY_RUN off
Only after Steps 1–5 are green (in particular the function is deployed and the shared
code exists and equals `RETAIL_SHARED_CODE` — the selftest / logs WARN loudly if the
code is unset):
```sh
# set DRY_RUN=0 in .env or ecosystem.config.js env, then:
pm2 restart dw-signup-fulfillment --update-env
curl -s http://127.0.0.1:9856/healthz # -> "dry_run":false
```
---
## 7. Smoke-test LIVE (carefully)
- Trigger a real signup with a throwaway email you control → confirm the email
arrives carrying **`DWSAMPLES3`** (NOT a `DWSAMP-…` unique code — the service does
not mint one), and confirm **no new discount record was created by the service**
in the admin. Then, signed in as that customer, add **4 sample swatches + 1 full
roll** and redeem the code → **first 3 samples free, 4th sample paid, roll
unaffected** (this is the live proof of the roll-safety guarantee).
- `POST /trade/apply` a test application (email only, no `shopify_customer_id`, as the
public form sends) for an email that HAS a store account → open `/admin/trade` →
**Approve** → confirm the approve **resolved the customer by email**, the customer
now carries the `trade` tag + `custom.assigned_rep` metafield, and rep + applicant
emails went out. Then approve an application whose email has **no** store account →
confirm it **hard-fails** with `cannot_resolve_customer` and writes nothing.
Watch `pm2 logs dw-signup-fulfillment` throughout.
---
## ROLLBACK
1. **Flip DRY_RUN back on** (stops all live writes immediately):
```sh
# set DRY_RUN=1 in .env or ecosystem.config.js env
pm2 restart dw-signup-fulfillment --update-env
curl -s http://127.0.0.1:9856/healthz # -> "dry_run":true
```
2. **Delete the webhook**:
```sh
SHOP=designer-laboratory-sandbox.myshopify.com; TOKEN=<SHOPIFY_FULFILLMENT_TOKEN>
curl -s "https://$SHOP/admin/api/2024-10/webhooks.json" -H "X-Shopify-Access-Token: $TOKEN" # find the id
curl -s -X DELETE "https://$SHOP/admin/api/2024-10/webhooks/<id>.json" -H "X-Shopify-Access-Token: $TOKEN"
```
3. To stop the retail offer entirely: **deactivate the `DWSAMPLES3` discount** in the
admin (Discounts). Already-emailed codes are one-use-per-customer and stop working
the moment the discount is deactivated. (Optional) `pm2 stop dw-signup-fulfillment`.
---
## Alternates (NOT wired — reference only)
- `lib/giftcard.js` — stored-value gift card. Reachable via
`POST /admin/retail/issue?mode=giftcard`. **Not safe as default**: the balance is
spendable on any line item, including full rolls (bounded to `$12.75`).
- `lib/giftcode-discount.js` — price-rule + Samples-collection-scoped 100%-off code.
Reachable via `POST /admin/retail/issue?mode=discount` (needs `SAMPLES_COLLECTION_ID`).
The wired default is `lib/retail-code.js` (sample-locked shared function code).
Switching away is a deliberate `server.js` change; do not do it without cause.