Skip to content

Session notes — claude-2026-09-21 ​

One person's notes. Never rewritten by anyone else; docs/state/HANDOFF.md §18 holds what is NOT done and STATE.md what is true now. This file is WHY, and exactly how each thing was checked — so the next session can tell a measured fact from a belief.

▶ HANDOFF — SAMO Shop redesign reviewed and shipped; four real holes found on the way ​

Seven deploys, all the same day, all <== exit 0 — ran to the end: ba3f8ba → aa94a14 → 82320ac → ea1b114 → afb10b2 → a6aa34d (the ✅ DEPLOYED line in STATE.md is the sha's only home; ask npm run deploy:owed). Migrations 0199, 0200, 0201 — all applied to samo-dev FIRST, then production.

Read these before touching the same code ​

If you touch…Read first
shop prices, checkout, place_shop_order§2 below + docs/mistakes/authz-rls.md (the order-RPC entry)
/notify, functions/_discord.js§4 below + docs/CONTEXT.md (the /notify tree)
people / students / team_members mirrors§6 below + docs/mistakes/postgres-schema.md (the "connecting is a merge" entry)
vite.config.js chunk names, the boot watchdog§5 below + docs/mistakes/frontend-ui.md (the analytics-*.js entry)
navbar on phonesdocs/mistakes/frontend-ui.md ("a layout fix scoped to ONE tab")

1. kita's storefront redesign — reviewed, fixed, merged, deployed ​

origin/feat/shop-redesign (kita, 45a8d68): "milkyway / espresso / sky" palette, Fraunces/Inter/Playfair fonts, full-page cream shop zone, pickup cards with date tiles, Latest Drops cards, desktop filter sidebar, custom type icons. Owner decision (DECIDED 2026-09-21): the new look is what the SAMO Shop team wants — it deliberately departs from the portal's white/green/orange brand inside the shop. Do not "fix" it back.

What I changed on top of it (7fa4fb8, then later commits):

  • Removed the navbar "/ samo shop" suffix. Measured in headless Chrome at 1280px: it widened the inline brand 173→306px and slid every nav pill ~66px each time the shop tab opened/closed; on phones it hid the Login label on the shop tab only. kita still wanted the identity → it now lives INSIDE the shop pane as a "MDKKU SAMO / samo shop" wordmark above the sub-nav (src/html/tab-shop.html, .sf-brand* in shop-storefront.css).
  • Deleted 24 dead shop.css rules the redesign left unreached — pruned with a real CSS parser (postcss), never by line number. .chip kept (admin uses it).
  • "Latest Drops" subtitle now matches banner vs card mode; card-mode arrows re-check overflow on resize.
  • Pickup card: the stripe panel shows a PICTURE (0201, §7).
  • Preorder products: the popup now says so (§8).
  • Found while checking phones: the navbar wordmark and mobile Login/Logout button OVERLAPPED by up to 23px on EVERY tab at 360–375px (kita's shop-only CSS had hidden it on the shop tab). Fixed in navbar.css; measured 320/360/375/390/430 in both auth states, no overlap.

⚠️ kita's branch feat/shop-redesign is fully merged into main; it was not deleted (not mine to delete).

2. Per-size prices — and the order RPC trusted the browser (0199) ​

Owner asked for a price per size, normal AND preorder. Reading the LIVE place_shop_order to add it showed it stored whatever unit_price and p_buyer_id the caller sent and was executable by anon; and a buyer could INSERT an order row already marked paid. Both measured ACCEPTED on production in a rolled-back transaction (probe subject checked NOT to be a shop admin — the first probe used the owner's own order and was an admin, so it proved nothing; caught before reporting).

0199: shop_products.price_by_size / preorder_price_by_size (jsonb, shape CHECKed); public.shop_unit_price(product, size) is the ONE home of the price rule (preorder: per-size preorder → preorder base → per-size normal → base; normal: per-size → base); place_shop_order for non-shop-admins = must be signed in, orders only as themselves, price COMPUTED (sent one ignored), fee 0, product must be on sale, size must exist; EXECUTE authenticated only; buyer INSERT policies on shop_orders/shop_order_items DROPPED. Shop admins keep a hand-typed price (walk-in orders via adminCreateOrder). JS mirror unitPriceFor (display only) — both checked against src/js/shop/price-cases.json (data.test.js + tools/shop0199-pricing.mjs, 20/20 dev + prod). The cart RE-PRICES itself whenever product data loads (repriceCart), because the checkout QR asks the buyer to transfer that number. Admin: per-size table in the product editor; list shows ฿min–max; the verify screen shows sizes.

3. Slips — "เพิ่มสลิป only works after deleting" — NOT reproduced; made faster ​

Replayed the exact second-slip write on the owner's order EP1349 as the buyer (rolled back): ACCEPTED. Headless Chrome with stubbed network: the real page code uploaded and saved 2 slips. The order's own timeline shows one upload taking ~60 s (13:44:30 → 13:45:29) — the likely experience. Fix: slips are downscaled to 2000px WebP before the Apps Script upload (SLIP_MAX_EDGE, uploads.js); a 3.1 MB phone PNG went up as 0.56 MB. Multiple slips are kept (recommended to owner: two transfers / clearer re-take are real cases); admin verify + order modal already show every slip. ⚠️ Not reproduced ≠ not a bug. If it recurs, get the toast text / device.

4. Discord: a message per web order (notifyShopOrder) ​

Webhook DISCORD_SHOP_WEBHOOK, in /etc/samo-notify.env on the VM ONLY (installed via sudo, value never printed; length 121). ⚠️ The owner pasted the URL in chat — rotation advised once (HANDOFF §18). Unlike every other /notify action, it is NOT built from the client's payload: checkout posts {orderId, accessToken}; the service reads the order back with the anon key + that session (RLS: buyer or shop admin), refuses >30 min old, sends each id once (in-memory; released on failure). Content (owner asked for "who ordered, more detail"): buyer name (+ account if different, never an email), each item with size/colour/preorder/qty/unit/line total, amount to transfer + item count, slip status + count, pickup place, PromptPay account, buyer note, first product picture as thumbnail, Discord timestamp; green = slip attached, amber = none; title links /admin/?scan=<id>. Deliberately excluded: phone, email, slip image (a channel keeps them for ever). Tests assert both what appears and what never does. ⚠️ Never seen in the real channel — no real order has been placed since. The live service was checked only by its refusals (no token → refused; bogus token → order read HTTP 401). The owner was offered a marked test message; not sent. Walk-in orders an ADMIN creates are NOT announced (by design).

5. "Why does this site break with Stay/Userscripts when others don't?" ​

Rollup had named the portal's core shared chunk analytics-<hash>.js (first module in it was analytics.js). Measured on production: block that ONE file → the portal never boots (watchdog bar); block nothing → boots. On other sites a blocked analytics file costs analytics; here it cost the site. Fix: tools/chunk-names.mjs renames tracker-looking chunks core-* (output.chunkFileNames), guarded by src/js/chunk-names.test.js (reverted the rename → red). The boot watchdog now fetches every /assets/ file on failure and reports each as 200/404/BLOCKED in its copyable diagnostics. ⛔ NOT PROVEN that Stay was the blocker. EasyList, EasyPrivacy, AdGuard base/tracking/social run through @ghostery/adblocker against every served URL matched NOTHING. The August report (docs/mistakes/frontend-ui.md, "SyntaxError blamed on the DOCUMENT") had Stay injecting scripts + our entry failing. Status HYPOTHESIS until the owner's iPad (extensions ON) produces the new diagnostics.

6. Main card ↔ ทีม SAMO ↔ ระบบบ้าน — they drifted; now synced + visible (0200) ​

npm run proofs on prod was 43/48; house0194 60/61 red. Measured over every synced column: 38 disagreements, every one a value vs a BLANK — rows CONNECTED to an already-existing person (09-14 duplicate merge, 09-15 "place 10 more", a 09-19 new posting). Cause: mirrors fire on CHANGES to people; connecting a placement is a merge, and when the up-mirror adds nothing new, people is never written and the down-mirror never runs. Plus student_mirror_up's guard omitted the photo (photo-only house edits never reached the card). Owner rule (2026-09-21): "it should all sync … and detect mismatch." 0200: _registry_mismatches(person) (one definition), _registry_sync_person (fill the card's blanks from a placement, then write people so the ONE existing down-mirror runs — no second down-mirror), trigger zz_registry_link_sync after a connection, photo in the guard (compared as SET, coalesce), gated registry_mismatches() / repair_registry_mismatches() (house / team_edit / master), all 38 repaired in the migration. UI: ระบบบ้าน → ข้อมูลไม่ครบ → group "ข้อมูลไม่ตรงกันระหว่างบัตรหลัก ทีม SAMO และระบบบ้าน" with a ซิงก์ให้ตรงกัน button. Proof house0200-three-copies 13/13 dev + prod (red before at the connect and photo cases). ⚠️ The proof's FIRST version passed before the fix — its probe name differed from the card, so the up-mirror fired the down-mirror and hid the bug. Probes for a sync must be ALREADY IN SYNC. ⚠️ The admin panel was checked only with STUBBED data in headless Chrome, not signed in as a real house admin.

7. Pickup card picture (0201) ​

shop_pickup_batches.image_url (ADD only). Editor: pick → local preview → uploaded on SAVE (never on pick), downscaled; replaced file trashed only if no other announcement/product uses it. Storefront order: announcement picture → first linked product with a picture (looked up in ALL products) → stripe. ⚠️ The live announcement "น้องอุ่นใจผลิตเสร็จแล้ว" links a product that was DELETED — it shows the stripe until a shop admin adds a picture. photo-refcount.test.js required the new column to be classified; its NOT_A_PORTRAIT entry says who cleans it, and the stale "shop images have no cleanup path" reason was corrected.

8. Preorder popup never said "preorder" ​

The box showed a calendar icon + the admin's free-text note only. Now a fixed heading "สินค้า Preorder · สั่งจองล่วงหน้า" + the note, and the popup picture carries the card's NEW / PREORDER / sold-out tags.

9. Password login ("my friend has kkumail … can't login") — not a code bug ​

The friend is pru_jinji: Google account with a username + password set later, no email identity. Supabase auth logs (Management API analytics/endpoints/logs.all, ~24 h retention) showed 3 invalid_credentials at 09:05–09:06 Thai that day (failures carry no user id). The lookup RPC resolves her username; on samo-dev a throwaway account of the SAME shape (Google identity only + password) signed in by password fine (deleted after). So: wrong password (likely her KKU mail password). Answer given: sign in with Google → profile → change password. Also shipped: a clearer error when an EMAIL is typed and the password fails (550 Google accounts have no password at all). ⚠️ Hand-inserting auth.users rows needs every token column '', not NULL, or GoTrue answers 500 "Database error querying schema" — a probe artefact, not a real-account problem (0 real rows have NULLs; measured).

10. Promotions ("buy two, get a discount") — ADVICE ONLY, nothing built ​

Recommended: a shop_promotions table (scope: product/type/source/all; condition: min qty or subtotal; reward: %/฿ off/bundle price; dates; normal/ preorder/both), evaluated in place_shop_order (now possible — prices are server-side), stored as a discount line with the promotion snapshotted, best single promotion (no stacking). Hard parts named: orders split per PromptPay account; admin item edits / partial cancels vs a frozen discount. Waiting on the shop team's first real promotion to spec it.

Also fixed along the way ​

  • npm run proofs was broken on main for everyone: two manual seed scripts in tools/*.sql → moved to tools/manual/ (the runner treats every tools/*.sql as a proof).
  • STATE.md had a false "ALL 48 GREEN" line that I wrote after running only the shop proofs; corrected the same day. Lesson: run npm run proofs (all) before writing a count.

Harness notes (so you don't re-derive them) ​

  • Headless Chrome + CDP Fetch.requestPaused stubbing of *supabase.co/* and *script.google.com/* + a fake session in localStorage (sb-<ref>-auth-token, far-future expires_at) drives signed-in flows with no real account. Stub responses need CORS headers; every POST shows up twice in a naive log because of the OPTIONS preflight — log the method.
  • A reused --user-data-dir KEEPS the session — a "signed-out" run measured the signed-in button until localStorage.clear().
  • Emulation.setDeviceMetricsOverride sometimes doesn't take on a tab reused from an earlier run; close old targets first and print innerWidth.

Working docs. STATE.md is the status file and lives at the repo root, not here.